Джон Скит - C# для профессионалов тонкости программирования 3-е издание, новый перевод - 2014
Джон Скит - C# для профессионалов тонкости программирования 3-е издание, новый перевод - 2014
Джон Скит - C# для профессионалов тонкости программирования 3-е издание, новый перевод - 2014
in Depth
Third Edition
JON SKEET
MANNING
C#
ДЛЯ
ПРОФЕССИОНАЛОВ
ТОНКОСТИ ПРОГРАММИРОВАНИЯ
Третье издание
Новый перевод
ДЖОН СКИТ
ВИЛЬЯМС
Скит, Джон.
С42 С# для профессионалов: тонкости программирования, 3-е изд. : Пер. с англ. — М. : ООО
“И.Д. Вильямс”, 2014. – 608 с. : ил. — Парал. тит. англ.
ББК 32.973.26-018.2.75
Все названия программных продуктов являются зарегистрированными торговыми марками соответствующих фирм.
Никакая часть настоящего издания ни в каких целях не может быть воспроизведена в какой бы то ни было форме
и какими бы то ни было средствами, будь то электронные или механические, включая фотокопирование и запись на
магнитный носитель, если на это нет письменного разрешения издательства Manning Publication, Co.
Autorized translation from the English language edition published by Manning Publications Co., Copyright © 2014.
All rights reserved.
No part of this publication may be reproduced, stored in a retrieval system, or transmitted, in any form or by means
electronic, mechanical, photocopying, or otherwise, without prior written permission of the publisher.
Russian Language edition is published by Williams Publishing House according to the Agreement with R & I
Enterprises Internatiional, Copyright © 2014.
Научно-популярное издание
Джон Скит
C# для профессионалов: тонкости программирования
Третье издание
Верстка Т.Н. Артеменко
Художественный редактор В.Г.Павлютин
Предисловие. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .20
Благодарности . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 21
Об этой книге . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
Об авторе. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .27
Предисловие 20
Благодарности 21
Об этой книге 23
Кто должен читать эту книгу . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
Дорожная карта . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
Терминология, оформление и загружаемый код . . . . . . . . . . . . . . . . . . . . . . . . 26
От издательства . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
Об авторе 27
6
Содержание 7
Тайсон С. Максвелл,
старший инженер по программному обеспечению, Raytheon
Джо Албахари,
автор инструмента LINQPad
и книги C# 5.0. Полный справочник программиста
Алексей Нудельман,
исполнительный директор, C# Computing, LLC
Стюарт Каборн,
старший разработчик, BNP Paribas
Шон Рейли,
программист/аналитик, Point2 Technologies
Джим Холмс,
автор Windows Developer Power Tools
Френк Дженнин,
обозреватель Amazon UK
Вступление
Эрик Липперт,
архитектор по анализу языка C#
в компании Coverity
Предисловие
Вот так-так! При написании этого предисловия я начал с предисловия ко второму изданию,
которое начиналось со слов о том, как много времени прошло с тех пор, когда я писал преди-
словие к первому изданию. Второе издание теперь стало далеким воспоминанием и выглядит как
совершенно другая жизнь. Я не уверен, говорит ли это о темпе современной жизни или о моей
памяти, но в любом случае оно поучительно.
Со времен выхода первого издания и даже со второго характер разработки чрезвычайно изме-
нился. Это было обусловлено многими факторами, наиболее очевидным из которых стал, пожалуй,
возросший объем мобильных устройств. Однако многие проблемы остались теми же. Писать со-
ответствующим образом интернационализированные приложения по-прежнему трудно. Все так же
сложно изящно обрабатывать ошибки во всех ситуациях. Все еще довольно нелегко писать кор-
ректные многопоточные приложения, хотя эта задача значительно упростилась за счет улучшений,
годами вносимых в язык и библиотеки.
В контексте этого предисловия важнее всего то, что я считаю, что разработчики по-прежнему
должны знать используемый ими язык на том уровне, на котором они уверены в его поведении.
Разработчики могут не знать тонкие детали каждого применяемого обращения к API-интерфейсу
или даже непонятные краевые случаи в языке, которые не придется использовать1 , но основ-
ная часть языка должна быть подобна верному другу, на поведение которого разработчик может
положиться.
Я уверен, что в дополнение к букве языка, на котором вы разрабатываете программное обес-
печение, большое преимущество дает понимание его духа. Когда временами вы обнаруживаете,
что безуспешно стараетесь победить какую-то проблему, то при совершении попыток заставить
свой код работать так, как на это рассчитывали проектировщики языка, опыт принесет немалую
пользу.
1
Я должен сделать одно признание: я очень мало знаю о небезопасном коде и указателях в С#. Я просто никогда
не испытывал в них потребности.
Благодарности
Может показаться, что написание третьего издания должно быть несложным делом — ведь все
изменения сосредоточены в двух новых главах. На самом деле написание содержимого глав 15 и 16
“с чистого листа” было легкой частью. Намного труднее было незначительно корректировать текст
в остальных главах, проверяя любые аспекты, которые были актуальны несколько лет назад, но
теперь утратили смысл, и в целом удостоверяться в том, что вся книга соответствует тем высоким
стандартам, которые, как я полагаю, предъявляются читателями. К счастью, мне посчастливилось
иметь дело с людьми, которые поддерживали меня и позволили сделать книгу точной и сжатой.
Самое важное то, что моя семья вела себя замечательно как никогда. Моя жена Холли сама
является детским автором, поэтому наши дети привыкли к тому, что мы иногда замыкаемся в
себе, чтобы вложиться в редакционные сроки, но они все время оставались бодрыми и энергич-
ными. Холли спокойно относилась ко всему этому, и я признателен ей за то, что она ни разу не
напомнила мне о том, сколько книг она начала с нуля и благополучно завершила за то время, пока
я трудился над этим третьим изданием. Официальные рецензенты перечислены позже, но я хотел
бы персонально поблагодарить всех, кто заказал ранние копии третьего издания, искал опечатки
и предлагал изменения, постоянно спрашивая, когда выйдет книга. Сам факт наличия читателей,
с нетерпением ожидавших получения в свои руки окончательной книги, был крупным источником
вдохновения.
Я всегда ладил с командой сотрудников в издательстве Manning, и было настоящим удоволь-
ствием работать с несколькими давними друзьями по первому изданию, а также с новоприбывши-
ми. Майк Стивенс и Джефф Блейл профессионально организовали процесс принятия решений о
том, что менять из предыдущего издания, а что оставить как есть. Они все расставили по своим
местам. Энди Керолл и Кэти Теннант обеспечили, соответственно, квалифицированное техниче-
ское редактирование и корректуру, никогда не выражая недовольство стилем моего английского,
придирчивостью или общей неясностью. Производственная группа, как всегда, делала свою магию
за кулисами, но я все равно благодарен им: Дотги Марсико, Джанет Вейл, Марии Тюдор и Мэри
Пирджис. Наконец, я хотел бы поблагодарить издателя, Марьяна Бейса, за то, что позволил мне
написать третье издание и показал интересные перспективы на будущее.
Независимая рецензия чрезвычайно важна, и не только для обеспечения правильности тех-
нических деталей книги, но также для соблюдения равновесия и нужной интонации. Иногда
получаемые комментарии оказывали влияние на форму всей книги; в других случаях я вносил в
ответ весьма специфические изменения. В любом случае приветствовались любые отзывы. Итак,
я благодарю следующих рецензентов за то, что они сделали эту книгу лучше для всех нас: Энди
Криша, Баса Пеннингса, Брета Коллофа, Чарльза М. Гросса, Дрора Хелпера, Дастина Лейна,
Ивана Тодоровича, Джона Пэриша, Себастьяна Мартина Агилара, Тиаана Гелдеихайса и Тимо
Бреденурта.
Благодарности 22
Особенно хочу поблагодарить Стивена Тауба и Стивена Клири, чьи ранние рецензии по главе
15 были просто бесценными. Асинхронность — такая тема, которую необычайно сложно описать
ясно и точно, и их экспертные заключения существенно повлияли главу.
Разумеется, не будь команды проектировщиков С#, не появилась бы и эта книга. Их пре-
данность языку при проектировании, реализации и тестировании достойна подражания, и я с
нетерпением жду, что же они придумают в следующий раз. С тех пор как было опубликовано
второе издание, Эрик Липперт покинул команду проектировщиков C# ради нового сказочного
приключения, но я весьма признателен ему за то, что он по-прежнему смог выступить в качестве
технического рецензента в этом третьем издании. Я также благодарен ему за вступление, которое
он первоначально написал для первого издания и которое включено в настоящее издание еще раз.
Я ссылаюсь на мысли Эрика по разнообразным вопросам повсеместно в книге, и если вы еще не
читаете его блог (http://ericlippert.соm), то самое время начать делать это.
Об этой книге
Эта книга посвящена языку С#, начиная с версии 2 и далее — вот так все просто. Я очень ма-
ло раскрываю версию C# 1, а библиотеки .NET Framework и общеязыковую исполняющую среду
(Common Language Runtime — CLR) описываю, только когда они касаются языка. Это обдуман-
ное решение, в результате которого получилась книга, несколько отличающаяся от большинства
виденных мною книг по C# и .NET.
За счет предположения, что читатель располагает разумным объемом знаний C# 1, я избегаю
траты сотен страниц на представление материала, который, как я считаю, большинство читателей
уже понимает. Это обеспечивает мне пространство для раскрытия деталей более поздних версий
С#, из-за которых, как я надеюсь, вы читаете данную книгу. Когда я писал первое издание этой
книги, даже версия C# 2 была относительно незнакома некоторым читателям. К настоящему
времени почти все разработчики на C# имеют определенный опыт использования средств, вве-
денных в C# 2, но я все равно сохранил этот материал в третьем издании, поскольку он является
чрезвычайно фундаментальным для того, что появилось в дальнейшем.
чем многие бы из нас хотели. Однако я могу утверждать, что если вы прочитаете и поймете эту
книгу, то должны чувствовать себя комфортно с C# и свободно следовать своим инстинктам без
особого опасения. Речь идет не о том, что можно написать код, который никто другой не поймет,
поскольку в нем используются неизвестные закоулки языка, а о наличии уверенности в том, что
вы знаете доступные варианты и то, к какому пути следования вас подталкивают идиомы С#.
Дорожная карта
Структура книги проста. Есть пять частей и три приложения. Первая часть служит введением,
включая повторение тем, связанных с C# 1, которые важны для понимания более поздних версий
языка и часто вызывают путаницу. Во второй части раскрываются новые средства версии C# 2, в
третьей рассматривается версия C# 3 и т.д.
Бывают случаи, когда организация материала подобным образом означает, что мы будем воз-
вращаться к какой-то теме пару раз — в частности, делегаты были усовершенствованы в C# 2
и затем еще раз в C# 3, — но в моем безрассудстве смысл все же присутствует. Я предвижу,
что некоторые читатели будут применять в разных проектах разные версии языка; например, на
работе вы можете использовать C# 4, а дома экспериментировать с C# 5. Это значит, что удобно
пояснять, что к какой версии относится. Кроме того, такая организация способствует ощущению
контекста и эволюции — она отражает то, каким образом язык развивался с течением времени.
В главе 1 устанавливается сцена, для чего берется простой фрагмент кода C# 1 и затем раз-
вивается, чтобы продемонстрировать, каким образом последующие версии позволяют исходному
коду становиться более читабельным и мощным. Мы взглянем на исторический контекст, в кото-
ром расширялся язык С#, и технический контекст, в котором он действует в качестве завершенной
платформы; C# как язык построен на библиотеках платформы и мощной исполняющей среде для
превращения абстракции в реальность.
В главе 2 мы снова будем иметь дело с C# 1, рассматривая три специфичных аспекта: деле-
гаты, характеристики системы типов и разницу между типами значений и ссылочными типами.
Эти темы часто понимаются разработчиками на C# 1 в стиле “лишь относительно хорошо”, но
поскольку язык C# развивался и значительно усовершенствовал их, для освоения большинства
новых средств требуется глубокое понимание основ.
В главе 3 обсуждается крупнейшее средство C# 2, потенциально самое трудное для освоения:
обобщения. Методы и типы могут быть написаны обобщенным образом, с параметрами типов,
указанными вместо реальных типов, которые задаются в вызывающем коде. Поначалу обобщения
будут казаться не менее запутанными, чем это описание, но после того, как вы их поймете, вы
непременно удивитесь, как в принципе могли обходиться без них ранее. Если вам когда-либо
хотелось представлять целочисленное значение, равное null, то глава 4 как раз для вас. В ней
рассматриваются типы, допускающие null: средство, построенное на основе обобщений, которое
использует в своих интересах поддержку со стороны языка, исполняющей среды и инфраструкту-
ры.
В главе 5 описаны усовершенствования делегатов в C# 2. До сих пор делегаты можно было
применять только для обработки событий, таких как щелчки на кнопках. В C# 2 упростилось
создание делегатов, а библиотечная поддержка сделала их более удобными в ситуациях, отличных
от обработки событий.
В главе 6 будут исследоваться итераторы и легкий способ их реализации в C# 2. Итераторные
блоки используют лишь немногие разработчики, но поскольку технология LINQ to Objects постро-
ена на основе итераторов, они будут становиться все более и более важными. Ленивая природа их
выполнения также является ключевой частью LINQ.
В главе 7 представлено несколько мелких средств, введенных в C# 2, каждое из которых
делает жизнь чуть более приятной. Проектировщики языка сгладили несколько шероховатостей в
Об этой книге 25
От издательства
Вы, читатель этой книги, и есть главный ее критик и комментатор. Мы ценим ваше мнение
и хотим знать, что было сделано нами правильно, что можно было сделать лучше и что еще вы
хотели бы увидеть изданным нами. Нам интересно услышать и любые другие замечания, которые
вам хотелось бы высказать в наш адрес.
Мы ждем ваших комментариев и надеемся на них. Вы можете прислать нам бумажное или
электронное письмо, либо просто посетить наш веб-сервер и оставить свои замечания там. Одним
словом, любым удобным для вас способом дайте нам знать, нравится или нет вам эта книга, а
также выскажите свое мнение о том, как сделать наши книги более интересными для вас. Посылая
письмо или сообщение, не забудьте указать название книги и ее авторов, а также ваш обратный
адрес. Мы внимательно ознакомимся с вашим мнением и обязательно учтем его при отборе и
подготовке к изданию последующих книг. Наши координаты:
E-mail: [email protected]
WWW: http://www.williamspublishing.com
Надпись для иллюстрации на обложке этой книги гласит: “Музыкант”. Иллюстрация была
взята из альбома с коллекцией костюмов Османской империи, напечатанного 1 января 1802 года
Уильямом Миллером на Старой Бонд-стрит в Лондоне. Титульная страница утеряна, и к настояще-
му времени мы не смогли ее разыскать. В оглавлении альбома рисунки обозначены на английском
и французском языке, и для каждого рисунка указаны фамилии двух художников, трудившихся
над ней. Не сомневаюсь, что они были бы весьма удивлены, обнаружив, что их произведение
украшает обложку книги по программированию, вышедшей спустя две сотни лет.
Альбом с этой коллекцией был приобретен редактором из Manning на блошином рынке анти-
квариата “Garage” на 26-й западной улице в Манхэттене. Продавцом был американец, живущий
в Анкаре, и сделка произошла как раз, когда он собирал раскладку, завершив торговлю. У редак-
тора Manning не было с собой достаточной суммы наличными, а кредитную карту и чек продавец
вежливо отклонил. С учетом того, что он вечером улетал домой в Анкару, ситуация становилась
безнадежной. Каким было решение? Вполне достаточно оказалось старомодного устного соглаше-
ния, скрепленного рукопожатием. Продавец просто предложил перевести ему деньги через банк,
и редактор ушел с клочком бумаги с банковской информацией и альбомом под мышкой. Само
собой разумеется, мы перечислили деньги сразу же на следующий день, но все еще остаемся
признательными и восхищенными доверием, которое этот неизвестный человек оказал одному из
нас. Это напоминает нам то, что могло происходить только в старые добрые времена.
Мы в Manning оцениваем изобретательность, инициативность и, конечно же, юмористичность
книг на темы, связанные с компьютерами, на основе богатого разнообразия региональной жизни
два столетия тому назад, воскрешая из небытия рисунки из этой коллекции.
Часть I
Подготовка к путешествию
Каждый читатель этой книги обладает собственным уровнем мастерства и возлагает на нее
свои надежды. Возможно, вы эксперт, ищущий возможность заполнить пробелы, пусть и малые,
в существующих знаниях. Или вы считаете себя рядовым разработчиком с небольшим опытом
использования обобщений и лямбда-выражений, но желаете лучше понять, как они работают. А
может быть, вы довольно хорошо владеете версиями C# 2 и C# 3, но не имеете опыта применения
C# 4 или C# 5.
Как автор, я не могу считать всех читателей одинаковыми, да и не хочу, если бы даже мог.
Однако я надеюсь, что все читатели обладают двумя общими чертами: стремлением к более глу-
бокому пониманию C# как языка и наличием хотя бы базовых знаний C# 1. Обо всем остальном
я позабочусь сам.
Потенциально широкий диапазон уровней квалификации и является главной причиной суще-
ствования этой части книги. Возможно, вы уже знаете, чего ожидать от последующих версий С#,
либо наоборот — все это может оказаться для вас совершенно новым. Вы можете иметь осно-
вательные знания C# 1 — или же отставать в каких-то деталях, важность которых значительно
возрастает при освоении более поздних версий языка. К концу части I все это деление на уровни я
отброшу в сторону, но вы должны подойти к изучению остального материала книги с уверенностью
и пониманием того, что будет происходить позже.
В первых двух главах мы будем устремлять взгляд как вперед, так и назад. Одной из ключевых
тем этой книги является эволюция. Перед введением любого средства в язык члены команды
проектировщиков C# тщательно взвешивают, хорошо ли вписывается это средство в контекст того,
что уже существует, и намечают цели на будущее. Это обеспечивает ощущение согласованности
языка даже в разгар перемен. Чтобы понять, как и почему язык развивается, необходимо видеть,
откуда он пришел и куда двигается.
В главе 1 представлен вид с высоты птичьего полета на оставшиеся части книги, с кратким
взглядом на некоторые важнейшие особенности С#, появившиеся после версии 1. Я продемон-
стрирую развитие кода со времен C# 1, применяя новые средства поодиночке до тех пор, пока код
не станет совершенно неузнаваемым со своего скромного начального вида. Мы также ознакомимся
с терминологией, используемой в дальнейших материалах книги, и форматом кода примеров.
Глава 2 в большой степени сосредоточена на C# 1. Если вы эксперт в C# 1, можете пропустить
эту главу, но учтите, что в ней затрагиваются области C# 1, которые зачастую понимаются непра-
вильно. Вместо попытки объяснить язык в целом внимание в главе сосредоточено на возможностях,
которые являются фундаментальными для последних версий С#. Получив такую прочную основу,
можно смело переходить к рассмотрению C# 2 во второй части этой книги.
ГЛАВА 1
В этой главе...
• Развивающийся пример
• Композиция .NET
• Спецификация языка C#
Знаете, что мне по-настоящему нравится в динамических языках, таких как Python, Ruby и
Groovy? Они отбрасывают из кода все незначительное, оставляя только его сущность — части, ко-
торые действительно что-то делают. Скучная формальность уступает дорогу средствам наподобие
генераторов, лямбда-выражений и списковых включений.
Интересно отметить, что некоторые средства, стремящиеся придать динамическим языкам лег-
ковесный характер, ничего не делают для обеспечения своей динамичности. Разумеется, опреде-
ленные средства это делают — например, утиная (неявная) типизация и “магия”, используемая в
Active Record — но статически типизированные языки не обязательно должны быть неуклюжими
и тяжеловесными.
Давайте обратимся к С#. В некотором смысле язык C# 1 можно рассматривать как улучшен-
ную версию языка Java образца примерно 2001 года. Все сходства были слишком очевидными,
но в C# имелось несколько дополнений: свойства как основополагающая характеристика языка,
делегаты и события, циклы foreach, операторы using, явное переопределение методов, пере-
грузка операций и специальные типы значений — и это далеко не полный перечень. Понятно, что
предпочтения относительно языка — личное дело каждого, однако я воспринимал C# 1 как шаг
вперед от Java, когда только начал пользоваться им.
В тех пор все становилось только лучше. В каждую новую версию добавлялись важные сред-
ства, снижающие инстинктивный страх разработчиков, причем всегда в тщательно продуманной
манере и с минимальной обратной несовместимостью. Даже до появления в C# 4 возможности
Глава 1. Изменение стиля разработки в C# 31
применения динамической типизации там, где она действительно удобна, многие средства, тради-
ционно связываемые с динамическими и функциональными языками, нашли свое отражение в С#,
позволяя получать код, который проще в написании и сопровождении. Аналогично, хотя средства,
касающиеся асинхронной обработки, в C# 5 не точно совпадают с такими средствами в F#, мне
кажется, что последние оказали определенное влияние.
В настоящей книге я последовательно проведу вас по всем изменениям, предоставляя доста-
точно деталей, чтобы вы спокойно воспринимали те удивительные вещи, которые компилятор C#
теперь готов предложить. Тем не менее, обо всех них пойдет речь позже — в этой главе я в беше-
ном темпе постараюсь осветить столько, сколько смогу, едва переводя дыхание. Я объясню, что
имеется в виду при сопоставлении C# как языка и .NET как платформы, и предоставлю несколько
важных замечаний относительно кода примеров для остальных частей книги. После этого можно
углубляться в детали.
В этой одной главе мы не собираемся охватить абсолютно все изменения, внесенные в язык С#.
Тем не менее, мы рассмотрим обобщения, свойства с различными модификаторами доступа, типы,
допускающие значения null, анонимные методы, автоматически реализуемые свойства, усовер-
шенствованные инициализаторы коллекций и объектов, лямбда-выражения, расширяющие методы
(часто называемые также методами расширения), неявную типизацию, выражения запросов LINQ,
именованные аргументы, необязательные параметры, упрощенное взаимодействие с СОМ, динами-
ческую типизацию и асинхронные функции. Это проведет нас по всему пути от C# 1 до последней
версии, C# 5. Итак, приступим.
using System.Collections;
public class Product
{
string name;
public string Name { get { return name; } }
decimal price;
public decimal Price { get { return price; } }
public Product(string name, decimal price)
{
this.name = name;
this.price = price;
}
public static ArrayList GetSampleProducts()
{
ArrayList list = new ArrayList();
list.Add(new Product("West Side Story", 9.99m));
list.Add(new Product("Assassins", 14.99m));
list.Add(new Product("Frogs", 13.99m));
list.Add(new Product("Sweeney Todd", 10.99m));
return list;
}
public override string ToString()
{
return string.Format ("{0}:{1}", name, price);
}
}
В листинге 1.1 нет ничего такого, что было бы трудно понять — в конце концов, это всего
лишь код C# 1. Тем не менее, здесь демонстрируются три ограничения.
• На этапе компиляции тип ArrayList не имеет каких-либо сведений о том, что в нем
содержится. В список, создаваемый внутри метода GetSampleProducts(), может быть
случайно добавлена, скажем, строка, и компилятор не заметит этого.
• Мы предоставили открытые средства получения (get) для свойств, а это значит что соот-
ветствующие им средства установки (set), если бы они понадобились, пришлось бы также
делать открытыми.
• Присутствует масса незначительных деталей при создании свойств и переменных — код,
который усложняет довольно простую задачу инкапсуляции строки и десятичного значения.
Давайте посмотрим, каким образом C# 2 поможет улучшить ситуацию.
using System.Collections.Generic;
class Product
{
public string Name { get; private set; }
public decimal Price { get; private set; }
public Product(string name, decimal price)
{
Name = name;
Price = price;
}
Product() {}
public static List<Product> GetSampleProducts()
{
return new List<Product>
{
new Product { Name="West Side Story", Price = 9.99m },
new Product { Name="Assassins", Price=14.99m },
new Product { Name="Frogs", Price=13.99m },
new Product { Name="Sweeney Todd", Price=10.99m}
};
}
public override string ToString()
{
return string.Format("{0}:{1}", Name, Price);
}
}
Как видите, со свойствами не связан какой-либо код (или видимые переменные), а жестко зако-
дированный список строится совершенно по-другому. Поскольку переменные name и price теперь
отсутствуют, в классе приходится повсеместно применять свойства, улучшая согласованность. В
коде предусмотрен закрытый конструктор без параметров, предназначенный для выполнения новой
инициализации с помощью свойств. (Этот конструктор вызывается для каждого элемента списка
перед установкой свойств.)
В приведенном выше примере можно было бы полностью удалить открытый конструктор, но
тогда во внешнем коде не удалось бы создавать другие экземпляры товаров.
средства установки, не разрешено изменять открытым образом, но это можно сделать более оче-
видным, если устранить также и возможность закрытого изменения1 . К сожалению, сокращений
для свойств, допускающих только чтение, не предусмотрено, но C# 4 позволяет указывать имена
аргументов при вызове конструктора (листинг 1.4), что обеспечивает прозрачность инициализато-
ров C# 3 и отсутствие изменяемости.
Листинг 1.4. Использование именованных аргументов для получения прозрачного кода ини-
циализации (C# 4)
using System.Collections.Generic;
public class Product
{
readonly string name;
public string Name { get { return name; } }
readonly decimal price;
public decimal Price { get { return price; } }
public Product(string name, decimal price)
{
this.name = name;
this.price = price;
}
public static List<Product> GetSampleProducts()
{
return new List<Product>
{
new Product( name: "West Side Story", price: 9.99m),
new Product( name: "Assassins", price: 14.99m),
new Product( name: "Frogs", price: 13.99m),
new Product( name: "Sweeney Todd", price: 10.99m)
};
}
public override string ToStnng()
{
return string.Format("{0}:{1}", name, price);
}
}
C# 1 C# 2
Свойства, допускающие Закрытые средства
только чтение установки свойств
Слабо типизированные Строго типизированные
коллекции коллекции
С# 3 C# 4
Автоматически Именованные аргументы
реализуемые свойства для более ясных
Усовершенствованные вызовов конструкторов и
инициализаторы методов
коллекций и объектов
ка и его отображение.
Первое, что обнаруживается в листинге 1.5 — это определение дополнительного типа для
помощи в сортировке. Ничего страшного в нем нет, если не считать необходимость в написании
излишне большого объема кода в ситуации, когда сортировка нужна только в одном месте. Далее
обратите внимание на несколько приведений в методе Compare(). Приведения являются способом
сообщения компилятору о том, что вам известно больше информации, нежели ему, и обычно
означают возможность неправильного предположения. Если список ArrayList, возвращаемый
из метода GetSampleProducts(), в действительности содержит строку, работа кода нарушится
в том месте, где предпринимается попытка приведения этой строки к типу Product.
Приведение также присутствует в коде, отображающем отсортированный список. Оно не оче-
видно, т.к. компилятор добавляет его автоматически, в результате чего цикл foreach неявно
приводит каждый элемент списка к типу Product. Опять-таки, это приведение может дать сбой
во время выполнения, и здесь снова на выручку приходят обобщения из C# 2. В листинге 1.6
показан предыдущий код с единственным внесенным изменением, которое связано с использова-
нием обобщений.
products.Sort(new ProductNameComparer());
foreach (Product product in products)
{
Console.WriteLine(product);
}
Код компаратора в листинге 1.6 проще, поскольку он сразу получает объекты товаров. Приве-
дение здесь не нужно. Аналогично исчезает невидимое приведение в цикле foreach. Компилятор
по-прежнему должен учитывать возможность преобразования исходного типа последовательности
в целевой тип переменной, но в этом случае оба типа относятся к Product, и никакого специ-
ального кода преобразования генерироваться не будет.
Хотя улучшение заметно, но было бы неплохо сортировать товары, просто указывая нужное
сравнение и не реализуя для этого интерфейс. Именно это и делается в листинге 1.7, в котором с
помощью делегата методу Sort() сообщается, каким образом сравнивать два товара.
В этом листинге код выглядит так, будто производится вызов метода OrderBy() на списке, но
если заглянуть в документацию MSDN, можно выяснить, что такой метод в классе List<Product>
не существует. Обращение к нему становится возможным благодаря наличию расширяющего ме-
тода, механизм которого более подробно рассматривается в главе 10. На самом деле список боль-
ше не сортируется “на месте”, а производится извлечение его элементов в определенном порядке.
Иногда требуется изменять действительный список, но временами упорядочение без побочных
эффектов оказывается предпочтительнее.
Важными характеристиками этого кода являются компактность и читабельность (разумеется,
при условии, что понятен синтаксис). Нам необходим список, упорядоченный по названию, и это
в точности то, что выражает код. Он не указывает на необходимость сортировки путем сравнения
названия одного товара с названием другого товара, как это делалось в коде C# 2, или сорти-
ровки с использованием экземпляра другого тала, которому известен способ сравнения товаров
друг с другом. Код просто обеспечивает упорядочивание по названию. Такая простота в смыс-
ле выразительности является одним из ключевых преимуществ C# 3. Когда отдельные порции
запрашиваются и обрабатываются настолько просто, крупные трансформации могут оставаться
компактными и читабельными в рамках одного фрагмента кода. Это, в свою очередь, содействует
взгляду на мир, более ориентированному на данные.
В этом разделе была продемонстрирована дополнительная мощь C# 2 и C# 3, с множеством
пока еще необъясненного синтаксиса, но даже без понимания деталей можно заметить продвиже-
ние в направлении более ясного и простого кода. Такая эволюция отражена на рис. 1.2.
C# 1 C# 2 С# 3
Слабо типизированный Строго типизированный Лямбда-выражения
компаратор компаратор Расширяющие методы
Отсутствие возможности Сравнения с помощью Возможность
использования делегата делегатов оставления списка
для сортировки Анонимные методы несортированным
Это все, что можно было сказать о сортировке2 . Давайте перейдем к другой форме манипули-
рования данными — выполнению запросов.
Понять этот код несложно. Однако полезно помнить о том, насколько тесно переплетены эти
три задачи — организация цикла с помощью foreach, проверка соответствия критерию посред-
ством if и отображение товара с применением Console.WriteLine(). Зависимости между
ними очевидны из-за их вложенности друг в друга.
В листинге 1.11 видно, что C# 2 позволяет несколько выправить ситуацию.
В частности, прием разделения двух видов ответственности вроде этого делает очень простым
изменение проверяемого условия и предпринимаемого действия независимым образом. Необходи-
мые переменные делегатов (test и print) можно было бы передавать методу, и этот же метод
мог бы в конечном итоге проверять совершенно отличающиеся условия и выполнять абсолютно
разные действия. Естественно, всю проверку и вывод можно поместить в один оператор, что и
сделано в листинге 1.12.
C# 2
С# 1 С# 3
Отделение условия от
Тесная связь между Лямбда-выражения
вызываемого действия.
условием и действием. дополнительно
Анонимные методы
Оба жестко упрощают восприятие
упрощают написание
закодированы. условия.
делегатов.
Надеюсь, что вы согласитесь с тем, что ни одна из указанных альтернатив не выглядит особо
привлекательной. Фокус в том, что проблему можно решить путем добавления единственного
символа к объявлениям переменных и свойств. В .NET 2.0 решение упростилось за счет появления
структуры decimal.Nullable<T>, и в C# 2 предлагается дополнительный “синтаксический
сахар”, который позволяет изменить объявление свойства следующим образом:
decimal? price;
public decimal? Price
{
get { return price; } private set { price = value; }
}
Тип параметра конструктора изменен на decimal?, в результате чего в качестве аргумента мож-
но передавать значение null, а также применять в коде класса оператор вроде Price = null;.
Смысл значения null изменяется со “специальной ссылки, которая не указывает на какой-либо
объект” на “специальное значение любого типа, допускающего null, которое позволяет предста-
вить отсутствие полезных данных”, при этом все ссылочные типы и все типы, основанные на
Nullable<T>, считаются типами, допускающими null.
Глава 1. Изменение стиля разработки в C# 43
Код получается намного более выразительным, чем при любом другом решении. Остальная
часть кода функционирует без изменений — товар с неизвестной ценой будет трактоваться как
дешевле, чем $10, из-за особенностей обработки значений типов, допускающих null, в опе-
рациях сравнения “больше”. Для проверки, известна ли цена, можно сравнить ее с null или
воспользоваться свойством HasValue, поэтому вывод всех товаров с неизвестными ценами в C#
3 осуществляется с помощью кода, приведенного в листинге 1.14.
Код C# 2 будет похож на код из листинга 1.12, но только придется выполнить проверку на
предмет null в анонимном методе:
До появления C# 4 для этой цели в классе Product пришлось бы определить новую перегру-
женную версию конструктора. Версия C# 4 позволяет объявить для параметра price стандартное
значение (в этом случае null):
C# 1 C# 2/3
Выбор между Типы, допускающие null, C# 4
дополнительной работой по делают вариант с Необязательные параметры
поддержке флага, переходом дополнительной работой позволяют простое
на семантику ссылочных простым, а синтаксический определение стандартных
типов и применением сахар дополнительно значений.
“магического значения”. улучшает ситуацию.
Лично я нахожу ранее приведенные листинги более легкими для восприятия — единствен-
ное преимущество этого выражения запроса связано с упрощением конструкции where. Здесь
присутствует одно дополнительное средство — неявно типизированные локальные переменные,
которые объявлены с использованием контекстного ключевого слова var. Это позволяет компи-
лятору выводить тип переменной на основе значения, которое ей первоначально было присвоено;
в данном случае типом filtered станет IEnumerable<Product>. Ключевое слово var будет
довольно часто применяться в остальных примерах этой главы; это особенно удобно в книгах, где
экономия пространства, занимаемого листингами, в большом почете.
Но если выражения запросов настолько плохи, то почему вокруг них и LINQ в целом поднят
такой шум? Хотя выражения запросов не особенно полезны для решения простых задач, они
очень эффективны в более сложных ситуациях, когда код, основанный на эквивалентных вызовах
методов, было бы трудно читать (особенно в версии C# 1 или C# 2). Давайте несколько усложним
задачу, добавив еще один тип Supplier, который представляет поставщика.
Каждый поставщик имеет название Name (типа string) и идентификатор SupplierID (типа
int). Кроме того, к типу Product добавлено свойство SupplierID и должным образом скор-
ректирован пример данных. Надо признать, что способ назначения каждому товару поставщика
нельзя назвать объектно-ориентированным — он намного ближе к тому, как данные будут пред-
ставлены в базе данных. Это упрощает демонстрацию рассматриваемого средства в настоящий
момент, но в главе 12 будет показано, что LINQ позволяет использовать также и более естествен-
ную модель.
Теперь давайте взглянем на код в листинге 1.16, который выполняет соединение примеров
товаров с примерами поставщиков (очевидно на основе идентификатора поставщика), применяет
к товарам тот же самый фильтр по цене, что и ранее, сортирует по названию поставщика, а
затем по наименованию товара и, наконец, выводит названия поставщика и товара для каждого
соответствия. Это весьма приличный объем работы, который в ранних версиях C# реализовать
было совсем нелегко. Однако в LINQ решение довольно тривиально.
Вы можете обнаружить, что код в листинге 1.16 удивительно похож на SQL. И действительно,
первая реакция многих разработчиков на язык LINQ (до его внимательного исследования) —
его откладывание в сторону как попытки простого встраивания возможностей SQL внутрь языка
ради взаимодействия с базами данных. К счастью, хотя язык LINQ позаимствовал синтаксис и
некоторые идеи из SQL, работа с ним не требует наличия базы данных. Показанный до сих пор
код вообще не касается базы данных. На самом деле можно было бы запрашивать данные из
любых источников, например, XML.
<?xml version="1.0"?>
<Data>
<Products>
<Product Name="West Side Story" Price="9.99" SupplierID="1" />
<Product Name="Assassins" Price="14.99" SupplierID="2" />
<Product Name="Frogs" Price="13.99" SupplierID="1" />
<Product Name="Sweeney Todd" Price="10.99" SupplierID="3" />
</Products>
<Suppliers>
<Supplier Name="Solely Sondheim" SupplierID="1" />
<Supplier Name="CD-by-CD-by-Sondheim" SupplierID="2" />
<Supplier Name="Barbershop CDs" SupplierID="3" />
</Suppliers>
</Data>
Файл довольно прост, но как лучше всего извлекать из него данные? Каким образом его запра-
шивать? Как выполнять на нем соединение? Несомненно, это должно быть сложнее того, что
делалось в листинге 1.16, правильно? В листинге 1.17 можно оценить объем работы, необходимой
для выполнения запроса с помощью LINQ to XML.
Листинг 1.17. Сложная обработка файла XML с помощью UNO to XML (C# 3)
(string)p.Attribute("Name")
select new
{
SupplierName = (string)s.Attribute("Name"),
ProductName = (string)p.Attribute("Name")
};
foreach (var v in filtered)
{
Console.WriteLine("Supplier={0}; Product={1}",
v.SupplierName, v.ProductName);
}
Подход не настолько прямолинеен, т.к. системе необходимо сообщить о том, каким образом
должны восприниматься данные (в плане того, какие атрибуты в качестве каких типов должны
применяться), но об этом речь пойдет чуть позже. В частности, существует очевидная связь между
частями двух листингов. Не будь ограничений на длину печатной строки в книге, вы бы легко
заметили построчное соответствие между этими двумя запросами.
Все еще под впечатлением? Или недостаточно убедительно? Давайте теперь поместим данные
туда, где они с большой вероятностью должны находиться — в базу данных.
Теперь код должен выглядеть очень знакомо. Все, что находится ниже строки с конструкцией
join, было без изменений скопировано из листинга 1.16.
Несмотря на впечатляющий вид, возникает вопрос, касающийся производительности: зачем
извлекать все данные из базы и затем применять к ним указанные запросы и упорядочение .NET?
Глава 1. Изменение стиля разработки в C# 48
Почему бы ни поручить эту работу базе данных? Ведь это то, что база данных умеет хорошо
делать, не так ли? Безусловно, это так — но точно такую же работу выполняет и LINQ to SQL.
Код в листинге 1.18 выдает запрос к базе данных, который по существу является запросом,
транслируемым в SQL. Хотя запрос выражен в коде С#, он выполняется как запрос SQL.
Позже вы увидите, что для реализации такого соединения существует подход, в большей
степени ориентированный на отношения, когда схеме и сущностям известно отношение между
поставщиками и товарами. Тем не менее, результат будет тем же самым, что просто демонстрирует
сходные черты LINQ to Objects (язык LINQ работающий с коллекциями в памяти) и LINQ to SQL.
Язык LINQ обладает исключительной гибкостью — можно построить собственный поставщик
для взаимодействия с веб-службой или транслировать запрос в какое-то специализированное пред-
ставление. В главе 13 будет показано, насколько в действительности широко понятие LINQ и как
оно может выйти за рамки запрашивания коллекций.
Хотя код может выглядеть не настолько хорошо, как хотелось бы, он намного лучше, чем
в случае применения более ранних версий С#. На самом деле вам уже известны некоторые
продемонстрированные здесь средства C# 4, но есть и несколько других, менее очевидных средств.
Ниже представлен их список.
4
Во всяком случае, отчасти. С точки зрения компилятора C# это тип, но среде CLR о нем ничего не известно.
Глава 1. Изменение стиля разработки в C# 50
nameValue.Text = product.Name;
priceValue.Text = product.Price.ToString("c");
int stock = await stockLookup;
stockValue.Text = stock.ToString();
}
finally
{
productCheckButton.Enabled = true;
}
}
Полный код метода несколько длиннее, чем показано в листинге 1.21, т.к. он дополнительно
отображает сообщения о состоянии и очищает результаты в самом начале, но этот листинг со-
держит все важные части. Несколько фрагментов выделены полужирным — метод имеет новый
модификатор async и определены два выражения await.
Игнорирование указанных аспектов в данный момент даст возможность понять общее направ-
ление кода. Он начинается с просмотра каталога товаров и склада в поисках информации о товаре
и текущем его запасе. Затем метод ожидает до тех пор, пока не получит информацию о товаре,
и завершается, если каталог не имеет записи для заданного идентификатора. В противном случае
он заполняет элементы пользовательского интерфейса, предназначенные для названия и цены, и
после этого ожидает сведений о запасе товара на складе, чтобы также отобразить их.
Просмотры каталога товаров и склада являются асинхронными, однако они могли бы быть
операциями в базе данных или обращениями к веб-службам. Все это не имеет значения — во
время ожидания результатов поток пользовательского интерфейса в действительности не блокиру-
ется, хотя код внутри метода выполняется в этом потоке. После возвращения результатов метод
продолжает работу с того места, где он был приостановлен. Этот пример также демонстриру-
ет, что нормальное управление потоком выполнения (try/finally) действует в точности так,
как ожидалось. Что действительно удивляет в этом методе — это достижение им нужного вида
асинхронности без традиционной возни с запуском других потоков либо созданием экземпляров
BackgroundWorker, вызовом метода Control.BeginInvoke() или присоединением обратных
вызовов к асинхронным событиям. Конечно, аспектов для обдумывания по-прежнему немало —
асинхронность не стала легкой из-за применения ключевых слов async/await, но оказалась
менее утомительной и теперь требует написания намного меньшего объема стереотипного кода,
отвлекающего от внутренней сложности, которую вы пытаетесь контролировать.
Еще не закружилась голова? Расслабьтесь, дальше изложение пойдет не в таком высоком
темпе. В частности, будут объяснены краевые случаи, предоставлены дополнительные сведения о
том, почему появились разнообразные средства, и предложены некоторые указания относительно
того, где их уместно использовать.
До сих пор были показаны средства языка С#. Часть этих средств требует библиотечной
поддержки, а часть — поддержки времени выполнения. Давайте проясним, что имеется в виду.
Во многих местах этой книги я ссылаюсь на три вида средств: средства C# как языка, сред-
ства исполняющей среды, предоставляющей своего рода “механизм”, и средства библиотек ин-
фраструктуры .NET. Внимание в этой книге в основном сосредоточено на языке С#, а средства
исполняющей среды и библиотек инфраструктуры будут обсуждаться, только когда они имеют от-
ношение к самому языку С#. Средства часто будут пересекаться, но важно понимать, где проходят
их границы.
1.7.1 Язык C#
Язык C# определен своей спецификацией, которая описывает формат исходного кода С#,
включая синтаксис и поведение. В ней не затрагивается платформа, на которой будут выполняться
результаты компиляции, кроме нескольких ключевых точек взаимодействия. Например, языку
C# требуется тип по имени System.IDisposable, который содержит метод под названием
Dispose(). Это необходимо для определения оператора using. Аналогично, платформе нужна
возможность поддержки (в той или иной форме) типов значений и ссылочных типов наряду со
сборкой мусора.
Теоретически может существовать компилятор C# для любой целевой платформы, которая
поддерживает обязательные средства. К примеру, компилятор C# может вполне законно генери-
ровать вывод в форме, отличной от промежуточного языка (Intermediate Language — IL), которая
является общепринятой на момент написания этой книги. Исполняющая среда будет интерпрети-
ровать вывод из компилятора C# или преобразовывать его полностью в машинный код за один
шаг, не выполняя JIT-компиляцию. Хотя такие варианты встречаются сравнительно редко, они
все-таки существуют; например, в Micro Framework используется интерпретатор, такой как Mono
(http://mono-project.net).
С другой стороны, полная компиляция применяется в NGen и Xamarin.iOS — платформе для
построения приложений, ориентированных на iPhone и другие устройства с операционной систе-
мой iOS.
да в библиотеках намного превышает объем кода исполняющей среды, примерно как автомобиль
намного больше его двигателя.
Библиотеки инфраструктуры частично стандартизированы. В части IV спецификации CLI пред-
лагается несколько разных профилей (компактный (compact) и ядро (kernel)) и библиотек. Раздел
IV состоит из двух частей — общего описания библиотек, включающего идентификацию обяза-
тельных библиотек в каждом профиле, и детальных сведений о самих библиотеках в формате
XML. Именно такая форма документации генерируется в случае использования комментариев
XML в коде С#.
Многие средства внутри .NET не находятся в библиотеках. В случае написания программы,
которая работает только с библиотеками, причем корректно, вы обнаружите, что код безупречно
функционирует под управлением любой реализации — Mono, .NET или какой-нибудь другой.
Однако на практике едва ли не каждая программа любого размера будет взаимодействовать с
библиотеками, которые не были стандартизированы — например, Windows Forms или ASP.NET.
Проект Mono имеет собственные библиотеки, не являющиеся частью .NET, такие как GTK#, и в
нем реализованы многие нестандартизированые библиотеки.
Понятие .NET относится к сочетанию исполняющей среды и библиотек, предоставляемых
Microsoft, и также охватывает компиляторы для языков C# и VB.NET. Это можно рассматривать
как полноценную платформу разработки, построенную на основе Windows. Для каждого аспекта
.NET поддерживаются отдельные версии, что может стать источником путаницы. В приложении
В приведены краткие сведения о том, когда вышла та или иная версия, и какими возможностями
она обладает.
Если все это понятно, то осталось обсудить последний аспект перед тем, как погрузиться в
исследования С#.
Если фрагмент не содержит многоточия (...), то весь код должен рассматриваться в качестве те-
ла метода Main() программы. Если же многоточие присутствует, то весь код, который находится
до него, считается объявлениями методов и вложенных типов, а код после многоточия относится
к методу Main(). Например, взгляните на следующий фрагмент:
static string Reverse(string input)
{
char[] chars = input.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
...
Console.WriteLine(Reverse("dlrow olleH"));
Инструмент Snippy расширит его так, как показано ниже:
using System;
public class Snippet
{
static string Reverse(string input)
{
char[] chars = input.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
[STAThread]
static void Main()
{
Console.WriteLine(Reverse("dlrow olleH"));
}
}
В действительности инструмент Snippy включает гораздо больше директив using, но расши-
ренная версия уже стала длиннее. Следует отметить, что содержащийся класс всегда называется
Snippet, а все типы, объявленные внутри фрагмента, будут вложенными в этот класс.
Дополнительные сведения об использовании Snippy можно найти на веб-сайте книги (http://
csharpindepth.com/Snippy.aspx), где также доступны все примеры в виде фрагментов и
расширенных версий в рамках решений Visual Studio. Кроме того, понадобится также поддерж-
ка LINQPad (http://www.linqpad.net) — простого инструмента, разработанного Джозефом
Албахари, который особенно удобен при исследовании LINQ.
А теперь давайте посмотрим, что не так с приведенным выше кодом.
He принимая во внимание проверку допустимости аргументов, этот код успешно обратит по-
следовательность кодовых позиций UTF-16 внутри строки, но в некоторых случаях недостаточно
хорошо. Скажем, если отображаемый глиф образован из буквы е, за которой следует комбиниро-
ванный символ, представляющий ударение, то последовательность кодовых позиций меняться не
должна; в конечном итоге ударение окажется ошибочным символом. Или предположим, что стро-
ка содержит символ не из базовой многоязычной плоскости, а сформированный из суррогатной
пары — изменение порядка следования кодовых позиций даст в результате строку, которая будет
недопустимой в контексте кодировки UTF-16. Устранение указанных проблем приведет к намного
более сложному коду, отвлекающему внимание от демонстрируемого аспекта.
Пользуйтесь свободно кодом из книги, но имейте в виду сказанное здесь. Будет гораздо лучше,
если этот код послужит источником вдохновения, а не просто окажется дословно скопированным
в расчете на то, что он удовлетворит конкретным требованиям.
В конце концов, доступны другие книги, посвященные отдельным темам.
5
Точное местоположение спецификации зависит от системы, но в случае установки Visual Studio
2012 Professional спецификация находится в каталоге C:\Program Files (х86)\Microsoft Visual Studio
11.0\VC#\Specifications\1033.
Глава 1. Изменение стиля разработки в C# 56
1.9 Резюме
В настоящей главе были представлены (пока без объяснений) средства, которые более подробно
рассматриваются в оставшихся главах книги. Есть еще немало того, что здесь не обсуждалось, а
со многими средствами, показанными до сих пор, связаны дополнительные возможности. Будем
надеяться, что материал этой главы разжег интерес к остальным частям книги.
Хотя большая часть главы была посвящена описанию средств, мы также взглянули на ряд
областей, которые должны помочь в получении максимальной пользы от книги в целом. Я прояс-
нил, что понимаю под языком, исполняющей средой и библиотеками, а также объяснил, как будет
представлен в книге код примеров.
Есть еще одна область, которую необходимо раскрыть, прежде чем погружаться в детали воз-
можностей C# 2, и это язык C# 1. Очевидно, что я как автор понятия не имею об уровне ваших
знаний C# 1, однако мне известен ряд областей С#, которые часто вызывают концептуальные
проблемы. Некоторые из таких областей являются критически важными для понимания последу-
ющих версий С#, поэтому в главе 2 они будут рассматриваться более подробно.
ГЛАВА 2
В этой главе...
• Делегаты
2.1 Делегаты
Уверен, что вы интуитивно понимаете, чем является делегат, хотя, может быть, и не в состоя-
нии четко сформулировать это. Если вы знакомы с языком С и хотите описать делегаты другому
программисту на С, то, несомненно, всплывет термин указатель на функцию. В сущности, де-
легаты предоставляют определенный уровень косвенности: вместо непосредственного указания
поведения, которое должно быть выполнено, его каким-то образом “упаковывают” в объект. Такой
объект затем может использоваться подобно любому другому, и одной поддерживаемой им опера-
цией является выполнение инкапсулированного действия. В качестве альтернативы тип делегата
можно считать интерфейсом с единственным методом, а экземпляр делегата — объектом класса,
который реализует этот интерфейс.
Если это вам кажется абракадаброй, возможно, поможет пример. Он несколько необычен, од-
нако охватывает все аспекты, которые характеризуют делегаты. Обдумайте свое завещание — т.е.
последнюю волю. Например, это может быть набор инструкций: “оплатить счета, пожертвовать
на благотворительность, оставить остальное имущество кошке”. Завещание пишется перед смер-
тью и оставляется в подходящем безопасном месте. После смерти ваш поверенный будет (как вы
надеетесь) действовать согласно этим инструкциям.
Делегат в C# действует подобно завещанию в реальном мире — он позволяет указать последо-
вательность действий для выполнения в надлежащее время. Делегаты обычно используются, когда
коду, который желает выполнить действия, ничего не известно о том, какими должны быть эти
действия. Например, единственной причиной, по которой класс Thread знает, что именно запус-
кать в новом потоке, когда вы его стартуете, является предоставление конструктору экземпляра
делегата ThreadStart или ParameterizedThreadStart.
Рассмотрение делегатов начинается с четырех абсолютных основ, без которых все остальное
теряет смысл.
Фактически тип делегата — это список типов параметров и возвращаемый тип. Тип делегата
указывает вид действия, который может быть представлен экземплярами этого типа. Например,
взгляните на тип делегата, объявленный следующим образом:
Этот код говорит о том, что для создания экземпляра StringProcessor понадобится метод с
одним параметром (типа string) и возвращаемым типом void (т.е. метод ничего не возвращает).
Важно понимать, что StringProcessor действительно является типом, производным от клас-
са System.MulticastDelegate, который, в свою очередь, унаследован от System.Delegate.
Глава 2. Язык C# как основа всех основ 59
Он имеет методы, можно создавать его экземпляры, а также передавать ссылки на эти экземпля-
ры. Очевидно, с этим связан ряд особенностей, но если вы зададитесь вопросом, что происходит
в данной конкретной ситуации, то сначала подумайте о том, что было бы в случае применения
обычного ссылочного типа.
Делегаты иногда понимаются неправильно, поскольку слово делегат часто используется для опи-
сания и типа делегата, и экземпляра делегата. Различие между ними точно такое же, как между
другим типом и его экземплярами — например, сам по себе тип string отличается от отдельной
последовательности символов. В этой главе будут повсеместно применяться термины тип делега-
та и экземпляр делегата, чтобы максимально прояснить, о чем идет речь в конкретный момент.
Этот шаг предусматривает поиск (или написание) метода, который выполняет необходимое дей-
ствие, а также имеет ту же самую сигнатуру, что и применяемый тип делегата. Идея в том, чтобы
при вызове экземпляра делегата все используемые параметры совпадали, и была возможность
обработки возвращаемого значения (если оно есть) так, как планировалось — в общем, подобно
вызову обычного метода.
Рассмотрим следующие пять сигнатур методов в качестве кандидатов на применение с экзем-
пляром StringProcessor:
void PrintString(string x)
void PrintInteger(int x)
void PrintTwoStrings(string x, string y)
int GetStringLength(string x)
void PrintObject(object x)
Первый метод удовлетворяет всем условиям, поэтому его можно использовать для создания
экземпляра делегата. Второй метод принимает один параметр, но не string, так что он несов-
местим с типом StringProcessor. Третий метод имеет первый параметр корректного типа, но
также располагает еще одним параметром, следовательно, является несовместимым. Четвертый
метод принимает параметр подходящего типа, однако возвращаемый тип отличается от void. (Ес-
ли в типе делегата предусмотрен возвращаемый тип, то возвращаемый тип метода также должен
ему соответствовать.)
Пятый метод интересен — при любом обращении к экземпляру StringProcessor можно
вызывать метод PrintObject() с теми же аргументами, т.к. тип string является производным
от object. Имело бы смысл применять его для создания экземпляра StringProcessor, но в
C# 1 типы параметров делегата должны совпадать точно1 .
В C# 2 ситуация изменилась — об этом речь пойдет в главе 5. Четвертый метод в какой-то
мере подобен, поскольку нежелательное возвращаемое значение всегда можно проигнорировать.
Однако возвращаемые типы void и отличные от void в настоящее время всегда считаются
несовместимыми. Частично это объясняется тем, что другим аспектам системы (особенно JIT)
1
В дополнение к типам у параметров должны также совпадать и модификаторы, т.е. in (стандартный), out или
ref. Хотя параметры out и ref в делегатах используются довольно редко.
Глава 2. Язык C# как основа всех основ 60
При наличии типа делегата и метода с подходящей сигнатурой можно создать экземпляр этого
типа делегата, указав, что данный метод должен быть выполнен при обращении к экземпляру
делегата. Официальной терминологии для такой ситуации не предусмотрено, но в настоящей книге
это будет называться действием экземпляра делегата.
Точная форма выражения, применяемого для создания экземпляра делегата, зависит от то-
го, какой метод использует действие — статический или метод экземпляра. Предположим, что
Printstring() является статическим методом внутри типа по имени StaticMethods, а так-
же методом экземпляра в типе под названием InstanceMethods. Ниже приведены два примера
создания экземпляра StringProcessor:
Когда действие выражено статическим методом, необходимо указать только имя типа, а если
действие представляет собой метод экземпляра, то понадобится экземпляр этого типа (или про-
изводного от него типа), как делается обычно. Этот объект называется целью действия, и при
обращении к экземпляру делегата метод будет вызван как раз на данном объекте. Если действие
определено внутри того же самого класса (как это часто бывает, особенно при написании обра-
ботчиков событий в коде пользовательского интерфейса), уточнять его не придется — для методов
экземпляра3 неявно применяется ссылка this. Опять-таки, эти правила действуют, как если бы
метод вызывался напрямую.
Следует помнить о том, что экземпляр делегата будет препятствовать уничтожению своей цели
сборщиком мусора, если сам экземпляр делегата не может быть уничтожен. Это может привести
к очевидным утечкам памяти, особенно когда какой-то недолговечный объект подписывается на
событие, возникающее в долговечном объекте, используя самого себя в качестве цели. Долговеч-
ный объект косвенно удерживает ссылку на недолговечный объект, продлевая его существование.
Создавать экземпляр делегата не имеет смысла, если он не будет вызван в определенный мо-
мент. Давайте взглянет на последний шаг — вызов.
2
Слово стек здесь преднамеренно применяется нечетко во избежание привлечения не относящихся к делу деталей.
За дополнительными сведениями обращайтесь к статье “The void is invariant” (“Тип void является инвариантным”) в
блоге Эрика Липперта (http://mng.bz/4g58).
3
Разумеется, если действие является методом экземпляра и предпринимается попытка создать экземпляр делегата
внутри статического метода, по-прежнему необходимо предоставить ссылку на цель.
Глава 2. Язык C# как основа всех основ 61
Экземпляр делегата вызывается очень просто4 : нужно лишь вызвать метод на экземпляре
делегата. Сам метод называется Invoke(). Он всегда присутствует в типе делегата и имеет такой
же список параметров и возвращаемый тип, как в объявлении типа делегата. В рассматриваемом
примере метод выглядит следующим образом:
Вызов Invoke() выполнит действие экземпляра делегата, передавая ему любые аргументы,
которые были указаны при вызове Invoke(), и вернет возвращаемое значение действия (если его
типом не является void).
Несмотря на простоту, C# еще более упрощает дело; с переменной5 типа делегата можно
обращаться так, как если бы это был сам метод. Лучше всего представить происходящее в виде
цепочки событий, возникающих в разное время, как показано на рис. 2.1.
proс1("Hello");
Компилируется в...
proс1.Invoke("Hello");
PrintString("Hello");
Как видите, все получилось легко. Все компоненты на месте, так что можно приступать к
исследованию взаимодействия между ними.
Наблюдать все это в действии лучше всего в полноценном примере — наконец-то хоть что-
нибудь удастся запустить! Поскольку здесь вовлечено слишком много разных мелочей, на этот раз
приведен полный исходный код, а не фрагменты. В листинге 2.1 нет ничего особо удивительного —
это просто конкретный код, который мы обсудим.
4
Во всяком случае, это касается синхронного вызова. Чтобы вызвать экземпляр делегата асинхронно, можно
воспользоваться методами BeginInvoke() и EndInvoke(), однако данная тема выходит за рамки настоящей главы.
5
Или выражение любого другого вида, но обычно применяется переменная.
Глава 2. Язык C# как основа всех основ 62
using System;
delegate void StringProcessor(string input); ❶ Объявление типа делегата
class Person
{
string name;
public Person(string name) { this.name = name; }
public void Say(string message) ❷ Объявление совместимого метода экземпляра
{
Console.WriteLine("{0} says: {1}", name, message);
}
}
class Background
{
public static void Note (string note) ❸ Объявление совместимого статического метода
{
Console.WriteLine("({0})", note);
}
}
class SimpleDelegateUse
{
static void Main()
{
Person jon = new Person("Jon");
Person tom = new Person("Tom");
StringProcessor jonsVoice, tomsVoice, background;
Создание трех
jonsVoice = new StringProcessor(jon.Say); ❹ экземпляров
tomsVoice = new StringProcessor(tom.Say); делегата
background = new StringProcessor(Background.Note);
jonsVoice("Hello, son."); Вызов
tomsVoice.Invoke("Hello, Daddy!"); ❺ экземпляров
background("An airplane flies past."); делегата
}
}
Вначале объявляется тип делегата Ê. Затем создаются два метода (Ë и Ì), совместимые с
типом делегата. Один из них является методом экземпляра (Person.Say()), а другой — стати-
ческим методом (Background.Note()), что позволит продемонстрировать отличия в их приме-
нении, когда создаются экземпляры делегата Í. В листинге 2.1 созданы два экземпляра класса
Person, чтобы можно было увидеть разницу в целях делегата.
Обращение к jonsVoice Î приводит к вызову метода Say() на объекте Person с име-
нем Jon; аналогично обращение к tomsVoice предусматривает использование объекта Person
с именем Тоm. Ради интереса в этом коде реализованы два показанных ранее способа вызова
экземпляров делегата — явный вызов метода Invoke() и применение сокращения С#. Обычно
будет использоваться сокращение.
Вывод, получаемый в результате выполнения кода в листинге 2.1, довольно очевиден:
Глава 2. Язык C# как основа всех основ 63
После того как экземпляр делегата создан, в нем уже ничего не может быть изменено. Это поз-
воляет безопасно передавать ссылки на экземпляры делегата и объединять их с другими, не
беспокоясь о согласованности, безопасности в отношении потоков или внешних попытках изме-
нения их действий. Это похоже на строки, которые также являются неизменяемыми, и метод
Delegate.Combine() подобен String.Concat() — оба они объединяют существующие эк-
земпляры, чтобы сформировать новый экземпляр, никак не изменяя исходные объекты. В случае
экземпляров делегата исходные списки вызова сливаются вместе. Обратите внимание, что попытка
объединения null с экземпляром делегата приводит к тому, что значение null трактуется как
экземпляр делегата с пустым списком вызова.
Вы редко встретите явный вызов метода Delegate.Combine() в коде С#, т.к. обычно ис-
пользуются операции + и +=. На рис. 2.2 показан процесс преобразования, в котором задейство-
ваны переменные х и у одного и того же (или совместимого) типа делегата. Все это делается
компилятором С#.
Глава 2. Язык C# как основа всех основ 64
Как видите, это простое преобразование позволяет писать намного более лаконичный код. По-
мимо возможности объединения экземпляров делегата, с помощью метода Delegate.Remove()
можно удалять один экземпляр из другого, для чего в C# применяются вполне очевидные опера-
ции - и -=.
Метод Delegate.Remove(source, value) создает но-
вый делегат, получающий список вызова source, из которо- х += у;
го был удален список value. Если результатом оказывается
пустой список вызова, возвращается null.
После вызова экземпляра делегата все действия выпол-
няются по очереди. Если возвращаемый тип в сигнатуре де- х = х + у;
легата отличается от void, то метод Invoke() вернет зна-
чение, которое было возвращено последним выполненным
действием. Экземпляры делегата, возвращающего не void,
с более чем одним действием в списке вызова встречаются х = Delegate.Combine(х,у);
редко, поскольку возвращаемые значения всех других дей-
ствий не будут доступны, если только вызывающий код явно Рис. 2.2. Процесс преобразования,
не выполняет действия по одному за раз, используя метод применяемый в отношении
Delegate.GetInvocationList() для извлечения спис- сокращенного синтаксиса для
ка действий. объединения экземпляров делегатов
Если любое действие в списке вызова генерирует исклю-
чение, это предотвращает выполнение последующих действий. Например, когда вызывается экзем-
пляр делегата со списком вызова [а, b, с], и действие b генерирует исключение, это исключение
распространяется немедленно, так что действие с выполняться не будет.
Объединение и удаление экземпляров делегатов особенно полезно при работе с событиями.
Теперь, когда вы понимаете, что собой представляют объединение и удаление, можно поговорить
о событиях.
тельности вызываются методы (добавления и удаления)6 . Все, что можно делать с событием —
подписываться на него (добавлять обработчик события) или отменять подписку (удалять обработ-
чик события). Внутри методов события предпринимаются какие-то полезные действия, такие как
обнаружение добавляемых и удаляемых обработчиков события или обеспечение их доступности в
рамках класса.
Основная причина существования событий очень похожа на причину существования свойств —
они добавляют уровень инкапсуляции, реализуя шаблон “публикация/подписка” (см. мою ста-
тью “Delegates and Events” (“Делегаты и события”) по адресу http://csharpindepth.com/
Articles/Chapter2/Events.aspx). Точно так же как вы не хотите, чтобы другой код имел
возможность устанавливать значения полей, не позволяя их владельцу хотя бы проверить новые
значения, часто нежелательно, чтобы внешний по отношению к классу код мог произвольно из-
менять (или вызывать) обработчики для события. Разумеется, в класс могут быть добавлены
методы для предоставления дополнительного доступа — скажем, для очистки списка обработчи-
ков события или для инициирования события (другими словами, для вызова его обработчиков).
Например, метод BackgroundWorker.OnProgressChanged() просто вызывает обработчики
события ProgressChanged. Но если вы откроете доступ только к самому событию, то внешний
код будет иметь только возможность добавления и удаления обработчиков.
События, подобные полям, существенно упрощают реализацию всего этого — понадобится
единственное объявление. Компилятор превращает это объявление в событие со стандартными
реализациями методов добавления/удаления и закрытое поле того же самого типа. Коду внутри
класса видно это поле, а коду за пределами класса видно только событие. Это выглядит как
возможность обращения к событию, но на самом деле для вызова обработчиков события осу-
ществляется обращение к экземпляру делегата, ссылка на который хранится в поле.
Подробные сведения о событиях в этой главе не предоставляются — в последних версиях
языка C# механизм событий не изменился7 , но здесь нужно было указать на разницу между
экземплярами делегатов и событиями во избежание недоразумений в будущем.
• Сигнатура типа, описанного с помощью объявления типа делегата, определяет методы, кото-
рые могут использоваться для создания экземпляров делегата, и сигнатуру для вызова.
• Создание экземпляра делегата требует метод и (для методов экземпляра) целевой объект, на
котором этот метод вызывается.
• События не являются экземплярами делегатов — это всего лишь пары методов добавле-
ния/удаления (подобно средствам получения/установки свойств).
Делегаты представляют собой просто одно специфичное средство C# и .NET — мелкая деталь
по большому счету. В оставшихся разделах этого главы речь пойдет о гораздо более широких
темах. Первым делом мы выясним значение утверждения о том, что язык C# является статически
типизированным, и последствия, вытекающие из этого.
Возможно, вы ожидали встретить в этом утверждении также и слово строгой, и я был не прочь
его включить. Но хотя большинство людей могут вполне согласиться с тем, что язык обладает
перечисленными выше характеристиками, решение о том, считать ли язык строго типизирован-
ным, часто вызывает горячие споры, поскольку бытующие определения существенно отличаются.
Некоторые черты (предотвращение любых преобразований, явных или неявных) определенно отда-
ляют C# от статически типизированного языка, тогда как другие делают его довольно близким
к этому (или даже таковым), что относится и к версии C# 1. В большинстве прочитанных мною
статей и книг, в которых C# был описан как строго типизированный язык, понятие “строгая
типизация” фактически использовалось для обозначения статической типизации.
Глава 2. Язык C# как основа всех основ 67
При описании этого вида типизации применяется слово статическая из-за того, что анализ
доступных операций производится с использованием неизменных данных: типов выражений на
этапе компиляции. Предположим, что переменная объявлена с типом Stream; этот тип переменной
не изменяется, даже если значение переменной варьируется между ссылкой на MemoryStream,
ссылкой на FileStream или вообще не указывает на существующий поток (посредством ссылки
null). Даже в рамках статических систем типов может поддерживаться определенное динамиче-
ское поведение; фактическая реализация, выполняемая в результате вызова виртуального метода,
будет зависеть от значения, на котором производится вызов.
Идея неизменной информации является также мотивом, лежащим в основе модификатора
static, но в целом проще считать статический член принадлежащим самому типу, а не какому-то
отдельному экземпляру этого типа. Для большинства практических целей такие два случая исполь-
зования этого слова можно рассматривать как несвязанные друг с другом.
8
Это относится также и к большинству выражений, однако не ко всем. Определенные выражения не имеют типа,
например, вызовы методов void, но это не влияет на статус C# 1 как статически типизированного языка. Чтобы
избежать путаницы, в этом разделе используется слово переменная.
Глава 2. Язык C# как основа всех основ 68
ЧТО, ЕСЛИ?
о = "hello";
Console.WriteLine(о.Length);
о = new string[] {"hi", "there"};
Console.WriteLine(o.Length);
Этот код привел бы к обращению к двум совершенно не связанным друг с другом свойствам
Length — String.Length и Array.Length — за счет динамического исследования типов во
время выполнения. Подобно многим аспектам систем типов, существуют различные уровни дина-
мической типизации. Некоторые языки позволяют указывать типы там, где необходимо (возможно,
по-прежнему трактуя их динамически кроме случая присваивания), но разрешают использовать
нетипизированные переменные в других местах.
Хотя в этом описании неоднократно упоминался язык C# 1, полностью статическая типизация
в языке распространяется вплоть до версии C# 3 включительно. Позже вы увидите, что в версии
C# 4 появилась некоторая динамическая типизация, хотя в подавляющем большинстве кода при-
ложений на C# 4 будет применяться все та же статическая типизация.
Разница между явной и неявной типизацией важна только в статически типизированных язы-
ках. При явной типизации тип каждой переменной должен быть явно указан в ее объявлении.
Неявная типизация позволяет компилятору выводить тип переменной на основе ее использования.
Например, язык мог бы предписывать, что типом переменной является тип выражения, которое
применяется для присваивания ей начального значения.
Рассмотрим гипотетический язык, в котором для обозначения выведения типа используется
ключевое слово var9 . В табл. 2.1 показано, как код в таком языке можно было бы записать на
C# 1. Код в левой колонке не разрешен в C# 1, а в правой колонке представлен эквивалентный
допустимый код.
Надеюсь, теперь понятно, почему это касается только ситуаций со статической типизацией:
при явной и неявной типизации тип переменной известен на этапе компиляции, даже если он
не указан явным образом. В динамическом контексте на этапе компиляции переменная не имеет
типа, который можно было бы задать или вывести.
ции встречаются относительно редко. При неправильном обращении эти окольные действия могут
порядком навредить. Одним из них является нарушение системы типов.
Прибегнув к определенной магии, в таких языках можно заставить трактовать значение одного
типа так, как если бы оно было значением совершенно другого типа, не применяя никаких пре-
образований. Здесь не имеется в виду вызов метода, у которого оказалось то же самое имя, как
было показано ранее в примере с динамической типизацией. Речь идет о коде, который просмат-
ривает низкоуровневые байты внутри значения и интерпретирует их “некорректным” способом. В
листинге 2.2 приведен простой пример кода на языке С, помогающий понять сказанное.
#include <stdio.h>
int main(int argc, char** argv)
{
char *first_arg = argv[1];
int *first_arg_as_int = (int *)first_arg;
printf ("%d", *first_arg_as_int);
}
Если вы скомпилируете код листинга 2.2 и запустите его с простым аргументом "hello", то
увидите вывод значения 1819043176 — во всяком случае, в системе с архитектурой, имеющей
порядок следования байтов от младшего к старшему, компилятором, трактующим int как 32 бита
и char как 8 битов, и представлением текста с использованием кодировки ASCII или UTF-8. Код
считает указатель на char как указатель на int, поэтому его разыменование возвращает первые
4 байта текста, рассматривая их как число.
На самом деле, этот крошечный пример не идет ни в какое сравнение с другими потенци-
альными нарушениями — выполнение приведения между совершенно несвязанными структурами
очень легко может привести к полному хаосу. Нельзя сказать, что подобное происходит в реаль-
ной жизни очень часто, однако некоторые элементы системы типов языка С часто требуют от вас
указания компилятору о том, что он должен делать, не оставляя ему выбора, кроме как доверять
вам даже во время выполнения.
К счастью, ничего такого в C# не случается. Да, существует немало доступных преобразо-
ваний, однако данные определенного типа не удастся выдать за данные другого типа. Можно
попытаться с помощью приведения предоставить компилятору такую дополнительную (и некор-
ректную) информацию, но если компилятор решит, что выполнить такое приведение на самом деле
невозможно, он сообщит об ошибке — а если оно теоретически разрешено, но в действительности
некорректно во время выполнения, то среда CLR сгенерирует исключение.
Теперь, когда вы немного узнали о том, как C# 1 вписывается в более общие рамки систем
типов, имеет смысл упомянуть о некоторых недостатках, связанных с выбранными решениями.
Это вовсе не говорит о том, что решения оказались неправильными — просто им присущи ограни-
чения. Зачастую проектировщики языков должны выбирать между разными подходами, которые
добавляют различные ограничения или приводят к другим нежелательным последствиям. Нач-
нем со случая, когда вы хотите предоставить компилятору дополнительную информацию, но не
существует способа сделать это.
Глава 2. Язык C# как основа всех основ 70
object Clone()
Разумеется, сигнатура проста — как уже упоминалось, этот метод должен возвращать копию
объекта, на котором он был вызван. Это означает необходимость возвращения объекта того же
самого типа или, в крайнем случае, совместимого с ним типа (когда значение меняется в зависи-
мости от типа).
Был бы смысл иметь возможность переопределения этого метода с применением сигнатуры,
которая дает более точное описание того, что метод в действительности возвращает.
Например, в классе Person было бы неплохо реализовать интерфейс ICloneable со следу-
ющим методом:
Это не приводит к каким-либо нарушениям — код, ожидающий любой объект типа object,
по-прежнему будет работать. Такая возможность называется ковариантностью возвращаемых
типов, но, к сожалению, механизмы реализации интерфейсов и переопределения методов ее не
поддерживают. Вместо этого для достижения желаемого результата в случае интерфейсов исполь-
зуется обходной путь — явная реализация интерфейса:
Глава 2. Язык C# как основа всех основ 72
{
return Clone(); Вызов метода, не относящегося к интерфейсу
Любой код, который вызывает Clone() на выражении со статическим типом Person, будет
взаимодействовать с первым методом; если же типом выражения является ICloneable, то будет
вызван второй метод. Хоть это и работает, но выглядит безобразно. Ситуация находит зеркальное
отражение в параметрах, когда имеется интерфейсный или виртуальный метод с сигнатурой вро-
де void Process(string х), и кажется вполне логичным наличие возможности реализации
или переопределения данного метода с применением менее требовательной сигнатуры, такой как
void Process(object х). Это называется контравариантностью типов параметров и не
поддерживается подобно ковариантности возвращаемых типов. Здесь придется использовать тот
же самый обходной путь для интерфейсов и нормальную перегрузку для виртуальных методов.
Такие сложности работу не останавливают, однако вызывают раздражение.
Разумеется, разработчикам на C# 1 долгое время приходилось мириться со всеми этими про-
блемами, а разработчики на Java пребывали в аналогичной ситуации гораздо дольше. Хотя без-
опасность типов на этапе компиляции в целом является великолепной характеристикой, я что-то
не припомню частых случаев помещения кем-либо в коллекцию элемента неподходящего типа.
Меня вполне устраивает отсутствие ковариантности и контравариантности. Однако существуют
такие понятия, как элегантность и четкость выражения кодом своих целей, желательно без необ-
ходимости в предоставлении пояснительных комментариев. Даже если ошибки не возникают, на-
вязывание документированного контракта о том, что коллекция должна содержать только строки
(например), может оказаться затратным и хрупким с учетом изменяемости коллекций. Такого рода
контракт должна обеспечивать сама система типов.
Позже вы увидите, что язык C# 2 в этом плане небезупречен, хотя в него было внесено много
улучшений. Еще больше изменений появилось в версии C# 4, но даже в ней ковариантность
возвращаемых типов и контравариантность типов параметров отсутствует11 .
• Язык C# 1 является безопасным — нельзя трактовать один тип, как если бы он был другим,
если настоящее преобразование не доступно.
11
В C# 4 была введена ограниченная обобщенная ковариантность и контравариантность, но это не совсем то же
самое.
Глава 2. Язык C# как основа всех основ 73
слова class), которые представляют собой ссылочные типы, и структуры (объявляемые с приме-
нением ключевого слова struct), которые являются типами значений. Ниже перечислены другие
примеры.
• Типы массивов являются ссылочными типами, даже если типы элементов относятся к типам
значений (поэтому int[] будет ссылочным типом, хотя int — тип значения).
В левой части рис. 2.3 отражена ситуация, когда Point является классом (ссылочный тип), а
в правой части — когда тип Point определен как структура (тип значения).
В обоих случаях после присваивания p1 и р2 получают одинаковые значения. Но в случае,
когда Point имеет ссылочный тип, это значение является ссылкой: p1 и р2 ссылаются на один
и тот же объект. Когда Point имеет тип значения, значением p1 являются полные данные о
точке — значения х и у. Присваивание p1 переменной р2 копирует все эти данные.
Значения переменных хранятся там, где они объявлены. Значения локальных переменных все-
гда хранятся в стеке12 , а значения переменных экземпляра — там, где хранится сам экземпляр.
Экземпляры ссылочных типов (объекты) всегда хранятся в куче, как и статические переменные.
12
Это верно только для C# 1. Как будет показано далее в книге, в последующих версиях языка локальные
переменные при определенных обстоятельствах могут оказываться в куче.
Глава 2. Язык C# как основа всех основ 75
p1
p1
x y
ССЫЛКА
x y 10 20
p2 10 20 p2
x y
ССЫЛКА
10 20
Рис. 2.3. Сравнение поведения типа значения и ссылочного типа в отношении присваивания
Другое отличие между этими двумя разновидностями типа связано с тем, что от типов значе-
ний нельзя наследовать. В результате значение не нуждается в какой-то дополнительной инфор-
мации о том, к какому типу оно в действительности относится. Сравните это со ссылочными
типами, в которых каждый объект содержит в своем начале блок данных, идентифицирующих тип
этого объекта, и ряд другой информации. Тип объекта изменить невозможно — когда осуществля-
ется простое приведение, исполняющая среда всего лишь берет ссылку, проверяет, указывает ли
она на допустимый объект заданного типа, и возвращает ссылку, если она корректна, или генери-
рует исключение в противном случае. Самой ссылке не известен тип объекта, поэтому одна и та
же ссылка может использоваться для нескольких переменных разных типов. Например, взгляните
на следующий код:
Этот миф преподносится во многих формах. Некоторые люди уверены, что типы значений не
могут или не должны иметь методы либо обладать другим важным поведением, а должны исполь-
зоваться как простые типы для передачи данных, располагающие только открытыми полями или
простыми свойствами. Хорошим контрпримером может служить тип DateTime: ему рационально
быть типом значения в том смысле, что он является фундаментальной единицей вроде числа или
Глава 2. Язык C# как основа всех основ 76
Этот миф не повторяет разве что ленивый. Первая часть утверждения корректна — экземпляр
ссылочного типа всегда создается в куче. Проблемы кроются во второй части. Как уже упомина-
лось, значение переменной хранится там, где она объявлена, поэтому если есть класс с переменной
экземпляра типа int, то значение этой переменной для любого объекта будет всегда находиться в
том же месте, что и остальные данные объекта — в куче. В стеке размещаются только локальные
переменные (переменные, объявленные внутри метода) и параметры метода. В C# 2 и последу-
ющих версиях даже некоторые локальные переменные не находятся в стеке, как будет показано
при рассмотрении анонимных методов в главе 5.
Довольно спорно утверждать, что при написании управляемого кода вы должны возлагать заботу
об эффективном использовании памяти на исполняющую среду. На самом деле спецификация язы-
ка не предоставляет никаких гарантий относительно того, где будут храниться те или иные данные.
Будущая реализация исполняющей среды может быть в состоянии создавать некоторые объекты
в стеке, если ей известно, что это удастся, или же компилятор C# сможет генерировать код, в
котором стек почти не используется.
Пожалуй, это наиболее широко распространенный миф. Люди, которые утверждают подобное,
часто (хотя и не всегда) знают действительное поведение С#, но не понимают, что на самом деле
означает процедура “передачи по ссылке”. К сожалению, это запутывает тех, кому известно ее
значение.
Формальное определение передачи по ссылке выглядит относительно сложно. В нем приме-
няются различные термины из области вычислительной техники, такие как l-значение, но важно
понимать, что в случае передачи переменной по ссылке вызываемый метод может изменять зна-
чение переменной вызывающего кода за счет модификации значения своего параметра. А теперь
вспомните, что значение переменной ссылочного типа является ссылкой, а не самим объектом.
Содержимое объекта, на который ссылается параметр, можно изменить без необходимости в пе-
редаче самого параметра по ссылке. Например, в следующем методе модифицируется содержимое
объекта StringBuilder, но выражение в вызывающем коде будет по-прежнему ссылаться на тот
же самый объект, что и ранее:
Когда этот метод вызывается, значение параметра (ссылка на объект StringBuilder) пере-
дается по значению. Если вы измените значение переменной builder внутри метода (например,
с помощью оператора builder = null;), это изменение, вопреки мифу, не будет видно в вы-
зывающем коде.
Интересно отметить, что в мифическом утверждении неточной является не только часть “по
ссылке”, но также и часть “объекты передаются”. Сами объекты никогда не передаются, ни по
ссылке, ни по значению. При работе со ссылочным типом выполняется либо передача переменной
по ссылке, либо передача значения аргумента (ссылки) по значению. Помимо всего прочего, это
дает ответ на вопрос о том, что происходит в случае указания null для аргумента, передаваемого
по значению — если бы передавались объекты, то неминуемо возникла бы проблема, поскольку
null означает отсутствие объекта, подлежащего передаче! Вместо этого ссылка null передается
по значению таким же образом, как и любая другая ссылка.
Если после приведенного краткого объяснения еще остались вопросы, можете почитать мою
статью “Parameter passing in С#” (“Передача параметров в С#”), доступную по адресу http://
yoda.arachsys.com/csharp/parameters.html, в которой эта тема рассматривается более
подробно.
Существуют и другие мифы кроме указанных выше. Упаковка и распаковка привносят свою
долю в общее непонимание, которую я постараюсь устранить.
С учетом этих двух фактов следующие три строки кода на первый взгляд бессмысленны:
int i = 5;
object о = i;
int j = (int) о;
• Ссылки похожи на URL — они представляют собой небольшие порции данных, которые
позволяют получать доступ к реальной информации.
• Объекты ссылочных типов всегда находятся в куче, но значения типов значений в зависимо-
сти от контекста могут быть либо стеке, либо в куче.
• Когда ссылочный тип используется для параметра метода, по умолчанию аргумент будет
передаваться по значению, но самим значением является ссылка.
• Когда необходимо поведение, характерное для ссылочного типа, значения типов значений
упаковываются; распаковка представляет собой обратный процесс.
Теперь, когда кратко упомянуты все аспекты языка C# 1, которые вы должны освоить, насту-
пило время забежать вперед и выяснить, каким образом каждое средство совершенствовалось в
последующих версиях С#.
handler(null, EventArgs.Empty);
handler = HandleDemoEvent;
❷ Неявное преобразование в экземпляр делегата
handler(null, EventArgs.Empty);
handler = delegate(object sender, EventArgs e)
{
Console.WriteLine ("Handled anonymously"); Указание действия
❸ с помощью
// Обработан анонимным методом анонимного метода
};
handler(null, EventArgs.Empty);
handler = delegate
{ Использование
Console.WriteLine ("Handled anonymously again"); сокращения
❹ посредством
// Снова обработан анонимным методом
анонимного метода
};
handler(null, EventArgs.Empty);
Использование
MouseEventHandler mouseHandler = HandleDemoEvent;
❺ контравариантности
mouseHandler(null, делегатов
В первой части главного кода Ê в целях сравнения приведен код на C# 1. Все осталь-
ные делегаты используют новые средства C# 2. Преобразования групп методов Ë существенно
улучшает читабельность кода подписки на события — оператор вроде saveButton.Click +=
SaveDocument; намного проще для восприятия и не отвлекает внимание на маловажные детали.
Синтаксис анонимных методов Ì немного громоздкий, но он позволяет действию оставаться в
точке создания, вместо того чтобы находиться в другом методе, который еще придется отыскать,
чтобы понять, что в нем происходит. При использовании анонимных методов доступно сокраще-
ние Í, но такая форма может применяться, только когда не нужны параметры. Анонимные методы
обладают также и другими мощными возможностями, но мы рассмотрим их позже.
Последний создаваемый экземпляр делегата Î является экземпляром MouseEventHandler, а
не просто EventHandler, но метод HandleDemoEvent() по-прежнему используется благодаря
контравариантности, которая обеспечивает совместимость параметров. Ковариантность опре-
деляет совместимость возвращаемых типов. Ковариантность и контравариантность более подробно
рассматриваются в главе 5 Самую большую выгоду от них получают, пожалуй, обработчики со-
бытий, т.к. оказывается, что рекомендация от Microsoft о необходимости следования всех типов
делегатов, применяемых в событиях, одному и тому же соглашению имеет намного больше смыс-
ла. В C# 1 не играло роли, что два разных обработчика событий выглядели очень похожими —
для создания экземпляра делегата требовался метод с точно совпадающей сигнатурой. В C# 2
один метод можно использовать для обработки множества разных видов событий, особенно если
предназначение метода явно не зависит от событий (например, метод реализует регистрацию в
журнале).
В C# 3 для создания экземпляров типов делегатов предлагается специальный синтаксис, ис-
пользующий лямбда-выражения. Для его демонстрации будет применяться новый тип делегата.
Когда в .NET 2.0 среда CLR получила обобщения, стали доступными обобщенные типы делега-
тов, которые используются при многих обращениях к API-интерфейсу в обобщенных коллекциях.
В .NET 3.5 дополнительно появилась группа обобщенных типов делегатов под названием Func,
которые принимают параметры указанных типов и возвращают значение другого заданного типа.
В листинге 2.5 демонстрируется использование типа делегата Func и лямбда-выражений.
Глава 2. Язык C# как основа всех основ 81
Здесь Func<int, int, string> представляет собой тип делегата, который принимает два
целочисленных значения и возвращает строку. Лямбда-выражение в листинге 2.5 указывает, что
экземпляр делегата (содержащийся в func) должен умножать два целых числа и вызывать метод
ToString(). Такой синтаксис намного проще, чем в случае применения анонимных методов,
и он также обладает другими преимуществами, которые касаются количества выведений типов,
обеспечиваемых компилятором. Лямбда- выражения критически важны для LINQ, и вы должны
сделать их основной частью набора инструментов для работы с языком. Тем не менее, они не
ограничиваются работой только с LINQ — любой случай использования анонимного метода в C#
2 можно заменить лямбда-выражением в C# 3, почти всегда получая более короткий код.
Подводя итоги, ниже перечислены новые средства, связанные с делегатами.
• Анонимные методы — C# 2.
• Лямбда-выражение — C# 3.
В первых двух строках кода показана неявная типизация (использование ключевого слова var)
и инициализаторы анонимных объектов (конструкция new {...}), которые создают экземпляры
анонимных типов.
На данном этапе следует обратить внимание на два момента, которые ранее у многих вызывали
напрасные волнения. Первый момент — язык C# 3 по-прежнему остается статически типизиро-
ванным. Компилятор C# объявил переменные jon и tom как относящиеся к определенному типу,
а при взаимодействии со свойствами этих объектов они ведут себя подобно нормальным свой-
ствам — никакого динамического поиска не происходит. Просто вы (как автор исходного кода) не
можете сообщить компилятору, какой тип использовать в объявлении переменной, поэтому компи-
лятор сгенерирует его самостоятельно. Свойства также являются статически типизированными —
свойство Age имеет тип int, а свойство Name — тип string.
Второй момент связан с тем, что здесь не создаются два разных анонимных типа. Переменные
jon и tom имеют один и тот же тип, т.к. на основе имен свойств, их типов и порядка следования
компилятор выясняет, что может быть сгенерирован только один тип, который и будет назначен
обеим переменным. Это делается в рамках каждой сборки и упрощает написание кода, давая воз-
можность присваивать значение одной переменной другой переменной (например, к предыдущему
коду вполне допустимо добавить оператор jon = tom;) и аналогичные операции.
Расширяющие методы также предназначены для LINQ, но могут применяться и за его пре-
делами. Каждый раз представляйте ситуацию, когда нужно, чтобы какой-то тип инфраструктуры
располагал определенным метолом, для реализации которого вы должны написать статический
вспомогательный метод. Например, чтобы создать новую строку за счет обращения порядка сим-
волов в существующей строке, можно реализовать статический метод StringUtil.Reverse().
Фактически средство расширяющих методов позволяет вызывать этот статический метод, как если
бы он был определен в самом типе string, так что разрешено записывать следующий код:
string х = "dlrow olleH".Reverse();
Расширяющие методы также позволяют добавлять методы с реализациями к интерфейсам, и
LINQ в большой степени полагается на это, позволяя вызывать на интерфейсе IEnumerable<T>
все виды методов, которые ранее не существовали.
В C# 4 с системой типов связаны две возможности. Одна относительно небольшая возмож-
ность представляет собой ковариантность и контравариантность для обобщенных делегатов и ин-
терфейсов. Она присутствует в CLR, начиная с выхода .NET 2.0, но только с появлением C# 4
и обновлением обобщенных типов в библиотеке базовых классов (Base Class Library — BCL)
эта возможность стала доступна для использования разработчиками на С#. Другим, существенно
более крупным средством, хотя и не востребованным многими программистами, является динами-
ческая типизация в С#.
Вспомните введение в статическую типизацию, где предпринималась попытка обратиться к
свойству Length массива и строки через одну и ту же переменную. Так вот, в C# 4 это работа-
ет — естественно, когда оно нужно. В листинге 2.7 приведен тот же самый код за исключением
Глава 2. Язык C# как основа всех основ 83
dynamic о = "hello";
Console.WriteLine(о.Length);
о = new string[] {"hi", "there"};
Console.WriteLine(o.Length);
• Анонимные типы — C# 3.
• Неявная типизация — C# 3.
• Расширяющие методы — C# 3.
• Динамическая типизация — C# 4.
После рассмотрения такого довольно разнообразного набора возможностей, связанных с систе-
мой типов, давайте взглянем на средства, которые были добавлены к одной специфической части
типизации в .NET — типам значений.
расходы упаковки незначительны для одного вызова, они могут существенно повлиять на произво-
дительность при наличии коллекций, доступ к которым производится часто. Кроме того, увеличи-
вается объем необходимой памяти, требуемой для упакованных объектов. Обобщения исправляют
недостатки, связанные с производительностью и памятью, за счет использования реального ти-
па вместо универсального. Например, в .NET 1.1 было бы весьма неосмотрительно читать файл
и сохранять каждый его байт в виде элемента ArrayList, но в .NET 2.0 это вполне можно
реализовать с помощью List<byte>.
Второе средство решает еще одну распространенную причину нареканий, особенно когда речь
идет о работе с базами данных — невозможность присваивания null переменной, имеющей тип
значения. К примеру, концепция значения null типа int не существует, несмотря на то, что це-
лочисленное поле в таблице базы данных вполне может принимать значение null. Это затрудняет
моделирование таблицы базы данных с помощью статически типизированного класса, приводя к
появлению неуклюжего кода в той или иной форме. Типы, допускающие null, появились в .NET
2.0, а в C# 2 был включен дополнительный синтаксис для их простого использования.
В листинге 2.8 представлен небольшой пример.
х = 5;
if (x != null) ❷ Проверка наличия реального значения
{
int у = x.Value; ❸ Получение реального значения
Console.WriteLine (y);
}
int z = x ?? 10; ❹ Использование операции объединения с null
2.5 Резюме
По большей части в этой главе были описаны возможности C# 1. Цель заключалась не в
предоставлении полного описания каких-либо тем, а в сжатом изложении наиболее важных основ,
что позволит рассматривать появившиеся впоследствии средства, не отвлекаясь на эти основы.
Все раскрытые здесь темы являются фундаментальными для C# и .NET, но мне часто при-
ходилось сталкиваться с их неправильным толкованием. Хотя каждый отдельный аспект в главе
не обсуждался особо подробно, надеюсь, предложенные сведения поспособствуют более ясному
пониманию остального материала этой книги.
Все три основные темы, кратко затронутые в главе, были значительно расширены со времен
версии C# 1, и некоторые средства охватывают сразу несколько тем. В частности, добавление
Глава 2. Язык C# как основа всех основ 85
• Обобщения. Будучи наиболее важным новым средством в C# 2 (и, конечно же, в среде CLR
для .NET 2.0), обобщения делают возможной параметризацию типов и методов на основе
типов, с которыми они взаимодействуют.
• Типы, допускающие значения null. Типы значений, такие как int и DateTime, не поддер-
живают концепцию “отсутствия значения”; типы, допускающие null, позволяют представлять
отсутствие содержательного значения.
После описания этих главных и сложных новых средств языка C# 2 в главах, специально выде-
ленных для них, в главе 7 рассмотрение завершается представлением нескольких более простых
возможностей. Более простых не означает менее полезных; например, частичные типы критически
важны для улучшенной поддержки визуального конструирования в среде Visual Studio 2005 и по-
следующих версиях. Это же средство также полезно для другого генерируемого кода. Подобным
образом в наши дни разработчики на C# принимают за данное возможность написания свойства с
открытым методом получения и закрытым методом установки, хотя это появилось только в версии
C# 2.
Когда вышло первое издание этой книги, многие разработчики вообще еще не применяли C#
2. В 2013 году у меня сложилось впечатление, что редко когда удается найти разработчика, в
текущий момент использующего С#, который бы не опробовал, пусть даже поверхностно, версию
C# 2, возможно версию C# 3 и уж наверняка версию C# 4. Темы, раскрываемые в этой части,
являются фундаментом для понимания того, как работают более поздние версии языка С#; в
частности, изучить LINQ, не имея представления об обобщениях и итераторах, будет довольно
сложно. Материал главы, посвященной итераторам, также связан с асинхронными методами C# 5;
на первый взгляд эти два средства сильно отличаются друг от друга, однако оба они предполагают
построение компилятором конечных автоматов для изменения обычного потока выполнения.
Если вы имели дело с C# 2 и последующими версиями на протяжении какого-то времени, то
можете обнаружить, что многие материалы этой части вам уже знакомы, но я не сомневаюсь, что
вы все равно извлечете пользу, получив более глубокие знания представляемых деталей.
ГЛАВА 3
В этой главе...
• Ограничения типов
• Рефлексия и обобщения
• Ограничения обобщений
совершенно нова для вас, то будет над чем поломать голову, — но как только вы ухватите основ-
ную идею, обобщения быстро вам понравятся.
В этой главе будет показано, как использовать обобщенные типы и методы, предоставленные
другими (либо самой инфраструктурой, либо библиотеками от независимых разработчиков), и как
писать собственные типы и методы подобного рода. Попутно с помощью вызовов API-интерфейса
рефлексии мы также выясним, как работают обобщения, и ознакомимся с тем, каким образом
обобщения обрабатываются средой CLR. В завершение главы будут описаны некоторые наиболее
часто встречающиеся ограничения обобщений и возможные способы их обхода, а также приведено
сравнение обобщений в C# и аналогичных средств в других языках.
Однако в первую очередь необходимо понять проблемы, которые привели к возникновению
обобщений.
В каждом описании обобщений, которое мне приходилось читать (включая мое собственное), под-
черкивается важность проверки типов на этапе компиляции по сравнению с проверкой типов на
этапе выполнения. Раскрою вам один секрет: я не припоминаю случая, когда в выпущенном коде
исправлялась ошибка, вызванная непосредственно отсутствием проверки типов. Другими словами,
согласно моему опыту, приведения, помещаемые в код C# 1, всегда работают. Эти приведения
были своего рода предупреждающими знаками, заставляя нас думать о безопасности типов явно,
а не пускать ее на самотек. Но хотя обобщения могут и не привести к радикальному уменьшению
количества ошибок, связанных с нарушениями безопасности типов, обеспечиваемое ими улучшение
читабельности способствует сокращению числа ошибок по всем категориям. Код, который проще
понять, проще и сделать правильным. Подобным же образом, код, который должен быть надежным
в отношении неподходящих вызовов, намного проще написать корректно, имея соответствующие
гарантии со стороны системы типов.
Всего перечисленного уже вполне достаточно, чтобы считать обобщения полезными, но есть
еще и улучшения в плане производительности. Во-первых, компилятор может предпринять боль-
ше принудительных действий, оставляя меньшее число проверок на этап выполнения. Во-вторых,
компилятор JIT может трактовать типы значений более интеллектуальным способом, который поз-
воляет избегать упаковки и распаковки во многих ситуациях. В ряде случаев это может привести к
огромной разнице в производительности в терминах скорости выполнения и потребления памяти.
Многие преимущества обобщений могут выглядеть похожими на преимущества статически
типизированных языков перед динамически типизированными языками: лучшая проверка на эта-
пе компиляции, больший объем информации, выраженной прямо в коде, улучшенная поддержка
со стороны IDE-среды, более высокая производительность. Причина довольно проста: используя
общий API-интерфейс (вроде ArrayList), который не способен различать разные типы, вы на
самом деле находитесь в динамической ситуации в терминах доступа к этому API-интерфейсу.
Кстати, обратное, как правило, не верно — преимущества, обеспечиваемые динамическими язы-
ками, редко принимаются в расчет при выборе между обобщенными и необобщенными API-
интерфейсами. При наличии разумной возможности применения обобщений решение об этом
не требует особых размышлений.
Итак, памятуя обо всех прелестях, которые нам сулит C# 2, приступим к реальному исполь-
зованию обобщений.
В методе CountWords() сначала создается пустая карта для отображения string на int
Ê. Она позволит вычислять частоту использования каждого слова в заданном тексте. Затем с
помощью регулярного выражения Ë текст разбивается на отдельные слова. Регулярное выраже-
ние довольно сырое — из-за наличия точки в конце текста появится пустая строка, к тому же
слова do и Do будут подсчитываться отдельно. Указанные проблемы легко исправляются, но в
рассматриваемом примере это не делается, чтобы сохранить его простым.
Для каждого слова производится проверка, присутствует ли оно в карте. Если это так, су-
ществующее значение счетчика инкрементируется; иначе слову назначается новый счетчик с на-
чальным значением 1 Ì. Обратите внимание, что в коде инкрементирования приведение к int не
требуется; нам известно, что извлекаемое значение имеет тип int, на этапе компиляции. На шаге
увеличения счетчика фактически выполняется метод извлечения на индексаторе для карты, затем
собственно инкрементирование и, наконец, метод установки на индексаторе. Вместо этого можно
было бы указать явный оператор: frequencies[word] = frequencies[word] + 1;.
Последняя часть листинга 3.1 должна быть знакомой: перечисление по последовательности
Hashtable дает для каждого элемента сходный экземпляр (необобщенного) класса Dictionary
Entry с соответствующим образом установленными свойствами Key и Value Í. Но в C# 1
пришлось бы приводить значения слова и частоты, поскольку ключ и значение возвращались бы
в виде экземпляров типа object. Это также означало бы упаковку значения частоты. Правда,
вы не обязаны помещать значения слова и частоты внутрь переменных — можно было бы преду-
смотреть единственный вызов метода Console.WriteLine() с передачей ему entry.Key и
entry.Value в качестве аргументов. Переменные применялись, просто чтобы подчеркнуть от-
сутствие необходимости в каком-либо приведении.
А теперь, после ознакомления с примером, давайте посмотрим, что в первую очередь имеется в
виду, когда речь идет о Dictionary<TKey, TValue>. Что собой представляют ТКеу и TValue
и почему они в угловых скобках?
Внимание, жаргон!
С темой обобщений связана подробная терминология. Она включена здесь для справочных це-
лей, а также потому, что иногда терминология намного упрощает рассмотрение темы. Это так-
же может быть полезно, когда необходимо обратиться за справкой к спецификации языка, но
вряд ли данная терминология понадобится в повседневной жизни. Просто пока смиритесь с этим.
Большая часть терминологии определена в разделе 4.4 спецификации C# 5 (“Constructed Types”
(“Сконструированные типы”)) — туда и обращайтесь за дополнительной информацией.
Форма обобщенного типа, при которой ни одному из параметров типов не были предоставлены
аргументы типов, называется несвязанным обобщенным типом. Когда аргументы типов указаны,
говорят, что тип является сконструированным типом. Несвязанные обобщенные типы факти-
чески выступают в качестве шаблонов для сконструированных типов, во многом похоже на то,
как типы (обобщенные или нет) могут рассматриваться как шаблоны для объектов. Это своего
рода дополнительный уровень абстракции. На рис. 3.1 представлена графическая иллюстрация
сказанного.
Dictionary<TKey,TValue>
(несвязанный обобщенный тип)
Указание
аргументов типов
(и т.д.)
Dictionary<string,int> Dictionary<byte,long>
(сконструированный тип) (сконструированный тип)
Hashtable
Рис. 3.1. Несвязанные обобщенные типы выступают в качестве шаблонов для сконструированных типов,
которые затем служат шаблонами для действительных объектов, в точности как это делают необобщенные
типы
В качестве дополнительной сложности, типы могут быть открытыми (open) или закрытыми
(closed). Открытый тип — это такой тип, который по-прежнему содержит какой-то параметр
типа (например, как один из аргументов типов или как тип элементов массива), а закрытый тип
представляет собой тип, не являющийся открытым, т.е. каждый аспект типа точно известен. В
действительности весь код выполняется в контексте закрытого сконструированного типа. Един-
ственный раз, когда в коде C# можно встретить несвязанный обобщенный тип (кроме места его
объявления) — внутри операции typeof, которая рассматривается в разделе 3.4.4.
Глава 3. Параметризованная типизация с использованием обобщений 94
Важно отметить, что ни один из методов в табл. 3.1 не является на самом дате обобщенным.
Это обычные методы внутри обобщенного типа, просто было решено использовать в них параметры
типов, объявленные в виде части типа. Обобщенные методы будут рассматриваться в следующем
разделе.
Теперь, когда вы знаете, что означают ТКеу и TValue, а также для чего предназначены угло-
вые скобки, можно взглянуть, как объявления из табл. 3.1 будут выглядеть в определении класса.
Ниже показано, каким образом может выглядеть код для Dictionary<TKey, TValue>, хотя
действительные реализации методов опущены, а в реальности может быть определено намного
больше членов:
namespace System.Collections.Generic
{
public class Dictionary<TKey,TValue> Объявление обобщенного класса
: IEnumerable<KeyValuePair<TKey,TValue>> Реализация обобщенного интерфейса
{
public Dictionary() {...} Объявление метода с применением параметров типов
public void Add(TKey key, TValue value) {...} Объявление конструктора
без параметров
Если вам когда-нибудь придется рассказывать об обобщениях коллегам, то знайте, что для пред-
ставления параметров или аргументов типов принято применять предлог “из” — например, List<T>
вслух можно назвать “списком из элементов типа T”. В VB англоязычный вариант этого предло-
га (“of”) является частью самого языка: тип может быть записан как List(Of Т). При наличии
множества параметров типов я считаю разумным разделять их посредством слова, которое со-
ответствует назначению всего типа, поэтому, чтобы акцентировать внимание на отображении, я
использую фразу “словарь, отображающий string на int”, а не “кортеж из типов string и
int”.
Обобщенные типы могут быть перегружены на основе количества параметров типов, так что
допустимо определять МуТуре, МуТуре<Т>, MyType<T,U>, МуТуре<Т, U, V> и так далее, при-
чем в рамках одного и того же пространства имен. При перегрузке имена параметров типов во
внимание не принимаются — важно только их количество. Эти типы не связаны друг с другом
за исключением имени — например, стандартное преобразование одного типа в другой не суще-
ствует. То же самое справедливо и в отношении обобщенных методов: сигнатуры двух методов
могут отличаться только по количеству параметров типов. Хотя это может звучать как рецепт для
обеспечения катастрофы, прием удобен, когда нужно воспользоваться выведением обобщенного
типа, при котором компилятор может самостоятельно определить ряд аргументов типов. Мы вер-
немся к этой теме в разделе 3.3.2.
Теперь, когда вы уловили идею, положенную и основу обобщенных типов, давайте взглянем на
обобщенные методы.
Возвращаемый тип
(обобщенный список) Параметр типа Имя параметра
Глядя на объявление обобщенного типа или обобщенного метода, может быть непросто понять,
что оно означает, особенно если приходится иметь дело с обобщенными типами обобщенных ти-
пов, как это было в случае интерфейса, реализованного словарем. Важно не впадать в панику —
воспринять все хладнокровно и подобрать пример ситуации. Используйте для каждого параметра
типа разные типы и применяйте их последовательно. В данном случае давайте начнем с заме-
ны параметра типа в типе, который содержится в методе (часть <Т> в List<T>). Мы будем
придерживаться концепции списка строк, поэтому заменим Т типом string везде в объявлении
метода:
Это выглядит немного лучше, но по-прежнему приходится иметь дело с TOutput. Можно
сказать, что TOutput является параметром типа метода (прошу прощения за сбивающую с толку
терминологию), т.к. он находится в угловых скобках прямо после имени метода, поэтому давайте
попробуем указать в качестве аргумента типа для TOutput другой известный тип — Guid. Мы
снова должны заменить везде этот параметр типа упомянутым аргументом типа. Теперь метод
можно рассматривать, как если бы он был необобщенным, удалив часть с параметром типа из
объявления:
Итак, все выражено в терминах конкретного типа, что упрощает его восприятие. Несмотря на
то что реальный метод является обобщенным, ради лучшего понимания мы будем считать, что это
не так. Пройдемся по элементам этого объявления слева направо:
• метод возвращает List<Guid>;
• имя метода выглядит как ConvertAll;
• метод принимает единственный параметр по имени converter с типом Converter<string,
Guid>.
Осталось только узнать, что собой представляет тип Converter<string, Guid>. Неуди-
вительно, что Converter<string, Guid> является сконструированным обобщенным типом
делегата (несвязанный обобщенный тип выглядит как Converter<TInput, TOutput>), кото-
рый используется для преобразования строки в GUID-идентификатор.
Таким образом, имеется метод, который оперирует на списке строк, применяя конвертер для
построения списка GUID-идентификаторов. Разобравшись с сигнатурой метода, намного про-
ще понять документацию, которая подтверждает, что указанный метод создает новый список
List<Guid>, преобразует каждый элемент исходного списка в целевой тип, добавляет его в
новый список и затем возвращает итоговый список. Представление сигнатуры с помощью кон-
кретных терминов дает более чистую умозрительную модель и упрощает обдумывание того, что
может делать этот метод. Хотя такой прием может показаться чрезмерно простым, я нахожу его
удобным в отношении сложных методов даже сейчас. Сигнатуры некоторых методов LINQ, име-
ющих по четыре параметра типов, выглядят настоящими свирепыми животными, но помещение в
рамки конкретных терминов значительно укрощает их.
Просто чтобы доказать, что я не водил вас за нос, давайте посмотрим на метод ConvertAll()
в действии. В листинге 3.2 демонстрируется преобразование списка целых чисел в список чисел
с плавающей точкой, причем каждый элемент во втором списке представляет собой квадратный
корень из значения соответствующего элемента в первом списке. После преобразования результаты
выводятся на консоль.
Листинг 3.2. Метод List<T>.ConvertAll<TOutput>() в действии
List<double> doubles;
doubles = integers.ConvertAll<double>(converter); Вызов обобщенного метода
❸
для преобразования списка
foreach (double d in doubles)
{
Console.WriteLine(d);
}
Глава 3. Параметризованная типизация с использованием обобщений 98
Создание и заполнение списка Ê довольно прямолинейно — это всего лишь строго типизиро-
ванный список целых чисел. В присваивании converter Ë используется возможность, поддер-
живаемая делегатами (преобразования групп методов), которая появилась в C# 2 и обсуждается
более подробно в разделе 5.2. Несмотря на то что мне не нравится применять средство до того,
как оно будет полностью описано, отмечу, что использование синтаксиса делегатов C# 1 привело
бы к получению слишком длинной строки, не умещающейся в печатную страницу. Тем не ме-
нее, средство выполняет вполне ожидаемое действие. Здесь вызывается обобщенный метод Ì с
указанием аргумента типа таким же образом, как это делалось для обобщенных типов. Это одна
из ситуаций, когда можно было бы применить выведение типа, чтобы избежать явного указания
аргумента типа, но я предпочитаю совершать по одному шагу за раз. Вывод на консоль возвращен-
ного списка очень прост, и после запуска кода вы увидите вполне ожидаемые значения 1, 1.414...,
1.732... и 2.
В чем смысл всего этого? Разумеется, мы могли бы просто с помощью цикла foreach прой-
тись по списку целых чисел и непосредственно вывести значения их квадрата корней. Однако
необходимость в преобразовании списка элементов одного типа в список элементов другого ти-
па, с выполнением в отношении них определенной логики, возникает не так уж редко. Код,
используемый для реализации этого вручную, довольно прост, но версия, которая делает это по-
средством единственного вызова метода, легче в восприятии. Это часто бывает с обобщенными
методами — они нередко выполняют действия, которые ранее благополучно делались “длинным
способом”, но с помощью вызова метода они оказываются проще. До появления обобщений в
классе ArrayList существовав операция похожая на ConvertAll(), которая осуществляла
преобразование из object в object, однако она была значительно менее удачной. Анонимные
методы (описанные в разделе 5.4) также здесь помогут — если вводить дополнительный метод
нежелательно, преобразование можно указать встроенным образом. Как будет показано в части
III книги, LINQ и лямбда-выражения дополнительно развивают этот шаблон.
Обратите внимание, что обобщенные методы могут также быть частью необобщенных типов.
В листинге 3.3 обобщенный метод объявляется и применяется внутри обычного необобщенного
типа.
Обобщенному методу MakeList<T> необходим только один параметр типа (Т). Метод строит
список, содержащий два параметра. Стоит отметить, что при создании списка List<T> в методе
можно использовать Т в качестве аргумента типа. Как и при описанном ранее анализе обобщенных
Глава 3. Параметризованная типизация с использованием обобщений 99
объявлений, реализацию можно рассматривать (грубо говоря) как замену всех вхождений Т типом
string. Для вызова метода применяется тот же самый синтаксис, который вы видели ранее при
указании аргументов типов.
Пока все в порядке? Теперь вы должны понимать простые обобщения. Боюсь, что дальше будет
несколько сложнее, но если вы уловили основную идею обобщений, то тем самым преодолели са-
мое большое препятствие. Не переживайте, если все еще нс полностью ясно (особенно когда дело
доходит до терминов “открытый/закрытый несвязанный/сконструированный”); сейчас самое время
поэкспериментировать, чтобы взглянуть на обобщения в действии, прежде чем двигаться вперед.
Если вы не имели дело с обобщенными коллекциями ранее, можете просмотреть приложение
Б, в котором описаны доступные варианты. Типы коллекций обеспечивают простую отправную
точку для работы с обобщениями и широко используются практически в каждой нетривиальной
программе .NET.
Во время экспериментов вы можете обнаружить, что с трудом пройдена только часть пути.
После превращения одной части API-интерфейса в обобщенную часто возникает необходимость
в переписывании другого кода, чтобы сделать его также обобщенным или чтобы добавить приве-
дения, которые требуются вызовами новых более строго типизированных методов. Альтернативой
может быть строго типизированная реализация, использующая внутри обобщенные классы, но
оставляющая на время слабо типизированный API-интерфейс. Со временем вы обретете большую
уверенность в том, когда уместно применять обобщения.
FileMode> — что угодно. Это нормально при работе с коллекциями, которые не должны вза-
имодействовать со своим содержимым, но не все случаи использования обобщений выглядят по-
добным образом. Часто требуется вызывать методы на экземплярах параметра типа, создавать
их новые экземпляры или обеспечивать прием только ссылочных типов (либо только типов значе-
ний). Другими словами, необходимо указать правила, которые определяют, какие аргументы типов
считаются допустимыми для обобщенного типа или метода. В C# 2 это делается с помощью огра-
ничений.
Доступны четыре вида ограничений, имеющих общий синтаксис. Ограничения задаются в кон-
це объявления обобщенного метода или типа с применением контекстного ключевого слова where.
Как вы увидите позже, они могут объединяться вместе удобными способами. Однако сначала мы
по очереди исследуем все виды ограничений.
Ограничение первого вида позволяет обеспечить, что используемый аргумент типа является
ссылочным типом. Оно выражается как Т:class и должно быть первым ограничением, ука-
занным для данного параметра типа. Аргумент типа может быть любым классом, интерфейсом,
массивом, делегатом или другим параметром типа, о котором известно, что он относится к ссы-
лочному типу. Например, взгляните на следующее объявление:
• RefSample<string>
• RefSample<int[]>
А вот недопустимые закрытые типы:
• RefSample<Guid>
• RefSample<int>
Я умышленно сделал RefSample структурой (т.е. типом значения), чтобы подчеркнуть раз-
ницу между ограниченным параметром типа и самим типом. RefSample<string> по-прежнему
представляет собой тип значения с повсеместной семантикой значения — просто так получилось,
что в коде применяется тип string везде, где указано Т.
Когда параметр типа ограничен таким способом, можно сравнивать ссылки (включая null) с
помощью операций == и !=, но знайте, что в отсутствие любых ограничений будут сравниваться
только ссылки, даже если в рассматриваемом типе указанные операции перегружены (как это де-
лано, например, в типе string). Посредством ограничения преобразования типа (описано ниже)
можно получить в свое распоряжение гарантированные компилятором перегруженные версии
операций == и !=, которые в этом случае и будут использоваться, однако такая ситуация встре-
чается относительно редко.
Ограничение типа значения, выражаемое как Т : struct, позволяет обеспечить, что приме-
няемый аргумент типа является типом значения, включая перечисления. Однако сюда не входят
типы, допускающие null (по причинам, указанным в главе 4). Рассмотрим следующий пример
Глава 3. Параметризованная типизация с использованием обобщений 101
объявления:
• ValSample<int>
• ValSample<FileMode>
• ValSample<object>
• ValSample<StringBuilder>
На этот раз ValSample — это ссылочный тип, несмотря на то, что Т органичен типом зна-
чения. Обратите внимание, что System.Enum и System.ValueType сами по себе являются
ссылочными типами, так что они не могут выступать в качестве допустимых аргументов типов
для ValSample. Когда к параметру типа применено ограничение типа значения, сравнение с
использованием операций == и != запрещено.
Сам я редко применяю ограничения типа значения или ссылочного типа, хотя в следующей гла-
ве вы увидите, что типы значений, допускающие null, полагаются на ограничения типа значения.
Оставшиеся два ограничения, скорее всего, окажутся более полезными при написании собствен-
ных обобщенных типов.
Давайте снова обратимся к короткому примеру, на этот раз для метода. Просто чтобы показать,
насколько это удобно, приведена также и реализация метода:
Глава 3. Параметризованная типизация с использованием обобщений 102
Этот метод возвращает новый экземпляр любого указанного типа при условии, что в нем
имеется конструктор без параметров. Это означает, что вызовы CreateInstance<int>() и
CreateInstance<object>() допустимы, но вызов CreateInstance<string>() — нет, т.к.
в типе string конструктор без параметров отсутствует.
Не существует способа ограничения параметров типов для принудительного применения дру-
гих сигнатур конструктора. Например, невозможно указать, что должен присутствовать конструк-
тор, принимающий единственный строковый параметр. К большому разочарованию это так. Мы
рассмотрим данную проблему более подробно, когда будем исследовать различные ограничения
обобщений .NET в разделе 3.5. Ограничения конструктора типа могут быть удобны, когда нуж-
но использовать фабричные шаблоны, при которых один объект будет создавать другой объект в
случае возникновения в нем необходимости. Фабрики часто должны генерировать объекты, сов-
местимые с определенным интерфейсом, и здесь в действие вступает последний вид ограничений.
Последний (и наиболее сложный) вид ограничений позволяет указывать другой тип, в кото-
рый аргумент типа должен поддерживать неявное преобразование посредством преобразования
идентичности, ссылочного преобразования или упаковывающего преобразования. Допустимо так-
же определять, что один аргумент типа должен иметь возможность преобразования в другой
аргумент типа — это называется ограничением параметра типа. Такие ограничения затрудняют
понимание объявления, но временами они оказываются удобными. В табл. 3.2 приведены приме-
ры объявлений обобщенного типа с ограничениями преобразования типа, а также допустимые и
недопустимые варианты соответствующих сконструированных типов.
Третье ограничение в табл. 3.2, Т : IComparable<T>, является лишь одним примером при-
менения обобщенного типа в качестве ограничения. Вполне подойдут и другие вариации, такие
как Т : List<U> (где U — другой параметр типа) и Т : IList<string>.
Можно указывать несколько интерфейсов, но только один класс. Например, следующее объяв-
ление вполне допустимо (хотя удовлетворить его трудно):
class Sample<T> where Т : Stream,
IEnumerable<string>,
IComparable<int>
Но такое объявление не годится:
class Sample<T> where T : Stream,
ArrayList,
IComparable<int>
Поскольку в любом случае тип не может быть унаследован напрямую от более чем одного
класса, такое ограничение обычно либо невозможно (подобно представленному выше), либо его
часть будет избыточной (например, указание на то, что тип должен быть производным от Stream
и MemoryStream).
Существует еще один набор ограничений: указываемый тип не может быть типом значения,
запечатанным классом (таким как string) или любым из следующих “специальных” типов:
• System.Object
• System.Enum
• System.ValueType
• System.Delegate
Ограничения преобразования типа, пожалуй, можно считать наиболее удобным видом, т.к. они
означают возможность использования членов указанного типа на экземплярах параметра типа.
Особенно полезным примером может служить ограничение Т : IComparable<T>, которое поз-
воляет сравнивать два экземпляра типа Т осмысленным и непосредственным образом. Пример
такого сравнения (а также обсуждение других форм сравнений) приводится в разделе 3.3.3.
Глава 3. Параметризованная типизация с использованием обобщений 104
Объединение ограничений
Ранее уже упоминалось о возможности наличия множества ограничений, и вы видели это в дей-
ствии для ограничений преобразования типа, однако пока еще не было показало, как объединять
вместе ограничения разных видов. Очевидно, что тип не может быть одновременно ссылочным ти-
пом и типом значения, поэтому такая комбинация запрещена. Подобным же образом, каждый тип
значения располагает конструктором без параметров, так что указать ограничение конструктора,
когда уже имеется ограничение типа значения, не получится (хотя по-прежнему можно приме-
нять ограничение new Т() внутри методов, если Т ограничен типом значения). При наличии
нескольких ограничений преобразования типа, одним из которых является class, это ограниче-
ние должно находиться перед интерфейсами — кроме того, один и тот же интерфейс не должен
быть указан более одного раза. Разные параметры типов могут иметь различные ограничения,
каждое из которых вводится с помощью отдельного ключевого слова where.
Давайте рассмотрим ряд примеров допустимых и недопустимых объявлений.
Допустимые объявления:
class Sample<T> where Т : class, IDisposable, new()
class Sample<T> where T : struct, IDisposable
class Sample<T,U> where T : class where U : struct, T
class Sample<T,U> where T : Stream where U : IDisposable
Недопустимые объявления:
class Sample<T> where T : class, struct
class Sample<T> where T : Stream, class
class Sample<T> where T : new(), Stream
class Sample<T> where T : IDisposable, Stream
class Sample<T> where T : XmlReader, IComparable, IComparable
class Sample<T,U> where T : struct where U : class, T
class Sample<T,U> where T : Stream, U : IDisposable
Последний пример объявления был включен в каждый список потому, что очень просто полу-
чить недопустимую версию объявления вместо допустимой, и сообщение компилятора об ошибке
мало чем поможет. Просто запомните, что каждый список ограничений для параметра типа дол-
жен указываться посредством собственного ключевого слова where. Третий пример допустимого
объявления довольно интересен; если U — тип значения, то как он может быть производным от
Т, который является ссылочным типом? Ответ: типом Т может быть object либо интерфейс,
который U реализует. Хотя, конечно, это довольно неуклюжее ограничение.
Теперь, когда вам известно все, что необходимо для чтения объявлений обобщенных типов,
давайте рассмотрим выведение аргументов типов, которое упоминалось ранее. В листинге 3.2
аргументы типов для метода List<T>.ConvertAll() были заданы явно, и то же самое делалось
в листинге 3.3 в отношении метода MakeList(). А сейчас мы предложим компилятору поработать
за нас, когда это возможно, упрощая вызов обобщенных методов.
Как видите, оба аргумента являются строками. Каждый параметр в методе объявлен с типом
T. Даже если бы часть <string> выражения вызова метода отсутствовала, было бы вполне
очевидным намерение вызывать метод с применением string в качестве аргумента типа для Т.
Компилятор позволяет не указывать эту часть, оставляя только код:
Немного аккуратнее, не так ли? Во всяком случае, короче. Разумеется, это не всегда означа-
ет, что код окажется более читабельным. В некоторых ситуациях читателю кода будет труднее
выяснить, какие аргументы типов должны использоваться, хотя компилятор делает это легко.
Я рекомендую судить о каждом случае по существу. Лично я предпочитаю, чтобы компилятор
выводил аргументы типов в большинстве ситуаций, где это срабатывает.
Компилятору точно известно, что мы используем string в качестве аргумента типа, поскольку
присваивание list тоже работает, а в нем указан аргумент типа (и это должно быть сделано).
Однако присваивание не оказывает влияния на процесс выведения типов аргументов. Это просто
означает, что если компилятор примет неправильное решение относительно того, какие аргументы
типов нужны, то вы, скорее всего, получите ошибку на этапе компиляции.
Но как компилятор может ошибиться? Предположим, что в действительности вы хотите ис-
пользовать object для аргумента типа. Параметры метода по-прежнему допустимы, но компиля-
тор считает, что вы рассчитываете на тип string, т.к. в вызове присутствуют две строки. Явное
приведение одного из параметров к object нарушит работу выведения типов, поскольку один из
аргументов метода ожидает, что типом Т должен быть string, а другой — что типом Т должен
быть object. Компилятор мог бы с учетом этого решить, что установка Т в object удовлетворит
всем требованиям, а установка Т в string — нет, однако в спецификации предусмотрено только
ограниченное количество шагов, которым нужно следовать. Эта тема довольно сложна в C# 2 и
даже больше усложняется в C# 3. Я не буду пытаться охватить здесь все тонкости правил C# 2,
но ниже перечислены базовые шаги.
1. Для каждого аргумента метода (в круглых, а не угловых скобках) попытаться вывести неко-
торые аргументы типов из обобщенного метода, используя относительно простые приемы.
Глава 3. Параметризованная типизация с использованием обобщений 106
2. Проверить, что все результаты, полученные на первом шаге, являются согласованными. Дру-
гими словами, если для отдельного параметра типа один аргумент подразумевает применение
одного аргумент типа, а другой — другого аргумента типа, не совпадающего с первым, то
выведение для вызова метода дает отказ.
3. Проверить, что были выведены все параметры типов, необходимые для обобщенного метода.
Не допускается разрешать компилятору выводить одни типы, но указывать другие явно —
выведение должно осуществляться либо для всех аргументов типов, либо не использоваться
вообще.
Чтобы не изучать все правила (к тому же я не рекомендую тратить на это время, если только вы
специально не интересуетесь мельчайшими подробностями), то вот вам простая рекомендация, как
действовать: пробуйте и смотрите, что в результате происходит. Если вы думаете, что компилятор
может быть способен вывести все аргументы типов, попытайтесь вызвать метод, не указывая ни
одного из них. Если это не удалось, указывайте аргументы типов явно. Вы потеряете не более
чем время, отнимаемое однократной компиляцией, но зато не придется держать в голове массу
лишней информации.
Для упрощения работы с обобщенными типами прием выведения типов можно комбинировать
с идеей перегрузки имен типов на основе количества параметров типов Пример будет приведен
через некоторое время, когда мы начнем собирать все вместе.
Когда тип, с которым предстоит работа, известен точно, то известно и его стандартное значе-
ние — значение, которое будет иметь неинициализированное поле, например. Однако если этот
тип не известен, то указать стандартное значение напрямую невозможно. Использовать значение
null нельзя, потому что это может быть не ссылочный тип. Не получится также применить
значение 0, поскольку тип может оказаться нечисловым.
Стандартное значение требуется довольно редко, но время от времени это может быть удобно.
Хорошим примером служит тип Dictionary<TKey,TValue> — он имеет метод TryGetValue(),
который работает подобно методам TryParse() числовых типов: использует выходной параметр
для извлекаемого значения и возвращаемое значение булевского типа для указания, успешно
ли прошло извлечение. Это означает, что метод должен располагать некоторым значением типа
TValue для заполнения выходного параметра. (Вспомните, что выходным параметрам необходимо
обязательно присвоить значения, прежде чем метод сможет нормально завершиться.)
Как раз для этого в C# 2 предлагается выражение для стандартного значения. В специфи-
кации оно не называется операцией, но вы можете считать его похожим на операцию typeof,
которая просто возвращает другое значение.
Глава 3. Параметризованная типизация с использованием обобщений 107
Шаблон TryХХХ()
В листинге 3.4 показан обобщенный метод, который вызывается с тремя разными типами:
string, int и DateTime. Метод CompareToDefault() диктует условие о том, что он может
применяться только с типами, которые реализуют интерфейс IComparable<T>, позволяя вызы-
вать CompareTo(Т) на переданном значении. Другим значением, используемым при сравнении,
является стандартное значение для типа. Поскольку string — ссылочный тип, стандартное зна-
чение равно null, а документация по методу CompareTo() гласит, что в случае ссылочных типов
любое значение должно быть больше null, и первым результатом оказывается 1. В следующих
трех строках представлены сравнения со стандартным значением int, демонстрирующие, что это
стандартное значение равно 0. Последняя строка выводит 0, указывая на то, что стандартным
значением типа DateTime является DateTime.MinValue.
Конечно, метод в листинге 3.4 даст отказ в случае передачи null в качестве аргумента —
строка с вызовом CompareTo() приведет к генерации исключения NullReferenceException.
Глава 3. Параметризованная типизация с использованием обобщений 108
Пока что не беспокойтесь об этом — как вскоре вы узнаете, существует альтернатива применению
интерфейса IComparer<T>.
Прямые сравнения
Хотя в листинге 3.4 было показано, что сравнение возможно, не всегда хочется ограничи-
вать свои типы реализацией интерфейса IСоmраrаblе<Т> или родственного ему интерфейса
IEquatable<T>, который предоставляет строго типизированный метод Equals(Т) для допол-
нения метода Equals(object), имеющегося во всех типах. Без дополнительной информации, к
которой обеспечивают доступ эти интерфейсы, в отношении сравнений можно делать немногим
более чем просто вызов Equals(object), приводящий в результате к упаковке сравниваемого
значения, когда оно является типом значения. (Для помощи в некоторых ситуациях предусмотрена
пара типов — мы вскоре обратимся к ним.)
Если параметр типа является неограниченным (т.е. никаких ограничений к нему не примене-
но), можно использовать операции == и !=, но только для сравнения значения этого типа с null;
сравнивать два значения типа Т друг с другом нельзя. Если аргумент типа относится к ссылоч-
ному типу, будет применяться нормальное ссылочное сравнение. В случае, когда аргумент типа,
предоставленный для Т, является типом значения, не допускающим null, сравнение с null бу-
дет всегда давать неравенство (поэтому такое сравнение может быть удалено JIT-компилятором).
Когда же аргумент типа представляет собой тип значения, допускающий null, сравнение будет
вести себя естественным образом, обеспечивая сравнение со значением null этого типа4 . (Не бес-
покойтесь, если последнее утверждение пока что не понятно — это прояснится во время чтения
следующей главы. К сожалению, некоторые средства слишком переплетены, чтобы можно было
описать одно из них без ссылки на другое.)
Когда параметр типа ограничен типом значения, операции == и != не могут использоваться
вообще. Если он ограничен ссылочным типом, то вид выполняемого сравнения зависит от того,
каким образом ограничен параметр типа. Если единственное ограничение заключается в том, что
параметр типа является ссылочным типом, выполняются простые ссылочные сравнения. Если он
дополнительно ограничен, чтобы быть производным от определенного типа, в котором операции ==
и != перегружены, применяются эти перегруженные версии. Однако будьте осторожны — допол-
нительные перегруженные версии операций, которые иногда делаются доступными посредством
аргумента типа, указанного в вызывающем коде, не используются. Сказанное продемонстрировано
в листинге 3.5 на примере простого ограничения ссылочного типа и аргумента типа string.
}
...
string name = "Jon";
string intro1 = "My name is " + name;
string intro2 = "My name is " + name;
4
На момент написания этой главы (материал проверялся в .NET 4.5 и предшествующих версиях) код, генерируе-
мый JIT-компилятором для сравнения значений неограниченных параметров типов с null, исключительно медленно
работал в случае типов значений, допускающих null. Если ограничить параметр типа Т как не допускающий null,
и затем сравнивать значение типа Т? с null, то такое сравнение будет выполняться намного быстрее. Это демон-
стрирует поле деятельности для будущей JIT-оптимизации.
Глава 3. Параметризованная типизация с использованием обобщений 109
Console.WriteLine(AreReferencesEqual(intro1, intro2));
Существуют четыре главных обобщенных интерфейса для сравнений. Два из них — IComparer<T>
и IComparable<T> — предназначены для сравнения значений на предмет порядка (является
ли одно значение меньшим, равным или большим другого значения). Другие два интерфейса —
IEqualityComparer<T> и IEquatable<T> — позволяют сравнивать элементы на предмет эк-
вивалентности согласно определенному критерию и вычислять хеш-код элемента (способом, сов-
местимым с тем же самым понятием эквивалентности).
Разделяя эти четыре интерфейса по-другому, следует отметить, что IComparer<T> и IEquality
Comparer<T> реализуются типами, которые обладают способностью сравнения двух разных зна-
чений, тогда как экземпляр типа, реализующего интерфейс IComparable<T> или IEquatable<T>,
позволяет сравнивать себя самого с другим значением.
Платформа .NET 4 предлагает большой объем такой функциональности в готовом виде — равно как
и для множества разных количеств параметров типов. Взгляните на Tuple<T1>, Tuple<T1,T2>
и тому подобные типы в пространстве имен System.
Кроме предоставления свойств для доступа к самим значениям будут переопределены методы
Equals() и GetHashCode(), чтобы позволить экземплярам этого типа успешно использоваться
в качестве ключей внутри словаря. В листинге 3.6 приведен полный код.
using System;
using System.Collections.Generic;
public sealed class Pair<T1, T2> : IEquatable<Pair<T1, Т2>>
{
private static readonly IEqualityComparer<T1> FirstComparer =
EqualityComparer<T1>.Default;
private static readonly IEqualityComparer<T2> SecondComparer =
EqualityComparer<T2>.Default;
private readonly T1 first;
private readonly T2 second;
public Pair(T1 first, T2 second)
{
this.first = first;
this.second = second;
}
public T1 First { get { return first; } }
public T2 Second { get { return second; } }
public bool Equals(Pair<T1, T2> other)
{
return other != null &&
FirstComparer.Equals(this.First, other.First) &&
SecondComparer.Equals(this.Second, other.Second);
}
public override bool Equals(object o)
{
return Equals(o as Pair<T1, T2>);
}
public override int GetHashCode()
{
return FirstComparer.GetHashCode(first) * 37 +
SecondComparer.GetHashCode(second);
}
}
Глава 3. Параметризованная типизация с использованием обобщений 111
Код в листинге 3.6 довольно прямолинеен. Образующие пару значения хранятся в подходя-
ще типизированных переменных-членах, а доступ к ним предоставляется с помощью простых
свойств, предназначенных только для чтения. Интерфейс IEquatable<Pair<T1,T2>> реализо-
ван с целью предоставления строго типизированного API-интерфейса, который позволит избежать
нежелательных проверок во время выполнения. Сравнения эквивалентности и вычисления хеш-
кодов используют стандартный компаратор эквивалентности для двух параметров типов — они
автоматически поддерживают значения null, что несколько упрощает код. Статические перемен-
ные, хранящие компараторы эквивалентности для Т1 и Т2, применяются по большей части ради
того, чтобы уместить код в печатную страницу, однако они также будут полезны в качестве опор-
ной точки в следующем разделе.
Вычисление хеш-кодов
Формула, используемая для вычисления хеш-кода, основана на двух “частичных” результатах, опи-
санных в книге Джошуа Блоха Effective Java, 2-е изд. (Addison-Wesley, 2008 г.). Она определенно
не гарантирует хорошее распределение хеш-кодов, но, по моему мнению, эта формула все же луч-
ше, чем применение операции побитового “исключающего ИЛИ”. За дополнительными сведениями
обращайтесь к указанной книге; там можно обнаружить также и многие другие полезные советы и
приемы.
Как теперь создать экземпляр построенного класса Pair? В данный момент понадобится запи-
сать примерно так:
Выглядит не особенно хорошо. Было бы неплохо использовать выведение типов, однако оно
работает только для обобщенных методов, а их нет. Если поместить обобщенный метод в обоб-
щенный тип, то перед тем, как этот метод можно будет вызвать, по-прежнему придется указывать
аргументы типов для обобщенного типа, что нивелирует всю цель. Решение заключается в том,
чтобы применить необобщенный вспомогательный класс с обобщенным методом внутри, как по-
казано в листинге 3.7.
Если вы читаете эту книгу впервые, проигнорируйте пока тот факт, что класс объявлен стати-
ческим — мы вернемся к этому вопросу в главе 7. Важно то, что имеется необобщенный класс с
обобщенным методом. Это означает, что предыдущий пример можно превратить в намного более
удачную версию:
Глава 3. Параметризованная типизация с использованием обобщений 112
будет создано и сколько типов будет унаследовано от класса SomeClass6 . Это знакомый сценарий
из C# 1 — но как он отображается на обобщения?
Ответ в том, что каждый закрытый тип имеет собственный набор статических полей. Это
можно было видеть в листинге 3.6, когда стандартные компараторы эквивалентности для Т1 и Т2
сохранялись в статических полях, но давайте проанализируем это более подробно на другом при-
мере. В листинге 3.8 создается обобщенный тип, включающий статическое поле. Затем значение
этого поля устанавливается для разных закрытых типов и результирующие значения выводятся на
консоль с целью демонстрации, что они разные.
Листинг 3.8. Доказательство того, что разные закрытые типы имеют разные статические по-
ля
class TypeWithField<T>
{
public static string field;
public static void PrintField()
{
Console.WriteLine(field + ": " + typeof(T).Name);
}
}
...
TypeWithField<int>.field = "First";
TypeWithField<string>.field = "Second";
TypeWithField<DateTime>.field = "Third";
TypeWithField<int>.PrintField();
TypeWithField<string>.PrintField();
TypeWithField<DateTime>.PrintField();
Каждое поле устанавливается в отличающееся значение, после чего поля выводятся вместе с
именем аргумента типа, использованного для этого закрытого типа. Ниже показан вывод, полу-
ченный в результате запуска кода из листинга 3.8:
6
Если придерживаться точности, то одно на домен приложения. В этом разделе мы будем предполагать, что имеем
дело только с одним доменом приложения. Концепции для разных доменов приложений работают с обобщенными
типами таким же образом, как с необобщенными. Переменные, декорированные атрибутом [ThreadStatic], тоже
нарушают это правило.
Глава 3. Параметризованная типизация с использованием обобщений 114
Первый вызов метода DummyMethod() для любого типа приведет к инициализации типа, во
время которой статический конструктор выводит диагностическую информацию. Каждый отлича-
ющийся список аргументов типов считается другим закрытым типом, поэтому вывод из кода в
листинге 3.9 выглядит следующим образом:
Outer<Int32>.Inner<String, DateTime>
Outer<String>.Inner<Int32, Int32>
Outer<Object>.Inner<String, Object>
Outer<String>.Inner<String, Object>
Outer<Object>.Inner<Object, String>
Как и в случае необобщенных типов, статический конструктор для любого закрытого типа вы-
полняется только один раз. Именно поэтому последняя строка кода в листинге 3.9 не создает ше-
стой строки вывода — статический конструктор для класса Outer<string>.Inner<int,int>
выполнился ранее, создав вторую строку вывода.
Без сомнений, при наличии необобщенного класса PlainInner внутри Outer попрежнему
был бы возможен только один тип Outer<T>.PlainInner на каждый закрытый тип Outer, так
что тип Outer<int>.PlainInner оказался бы отдельным от Outer<long>.PlainInner, со
своим набором статических полей, как было показано ранее.
Теперь, когда вы знаете, из чего образованы различные типы, мы должны проанализировать
возможное влияние этого на объем генерируемого машинного кода. Все не так плохо, как вы могли
подумать...
Глава 3. Параметризованная типизация с использованием обобщений 115
ref 12
10
ref 13
12
… …
13
Рис. 3.3. Визуальная демонстрация причины того, что при сохранении типов значений List<T> требует
намного меньше пространства, чем ArrayList
} Реализация
IEnumerator<T>.
public int Current { get { return current; } } ❸ Current неявным образом
Ясно, что полученный результат не особенно полезен, но код демонстрирует, через какие, пусть
и небольшие, испытания придется пройти для реализации обобщенной итерации должным обра-
зом — во всяком случае, если делать все собственноручно. (И это еще не учитывая затраты на
генерацию исключений, если доступ к свойству Current происходит в ненадлежащее время.)
Если вы считаете, что в листинге 3.10 содержится слишком много кода, как для простого вывода
чисел от 0 до 9, то не могу не согласиться с вами; кода было бы даже больше, если бы пона-
добилось организовать итерацию по чему-то более полезному. К счастью, как будет показано в
главе 6, во многих случаях C# 2 позволяет существенно сократить объем работ по реализации
итераторов. В листинге представлена полная версия, чтобы вы смогли оценить мелкие недостат-
ки, связанные с принятием решения о том, что интерфейс IEnumerable<T> должен расширять
интерфейс IEnumerable. Тем не менее, я не утверждаю, что такое решение было ошибочным;
это позволяет передавать любой тип IEnumerable<T> методу, который написан на C# 1 и при-
нимает параметр типа IEnumerable. Хотя важность данного решения снизилась по сравнению с
ситуацией 2005 года, оно по-прежнему является удобным способом перехода.
Потребуется только дважды применить трюк с явной реализацией интерфейса — один раз для
метода IEnumerable.GetEnumerator() Ë и один раз для свойства IEnumerator.Current Í.
В обоих случаях производится обращение к обобщенным эквивалентам (соответственно, Ê и
Ì). Еще одно добавление к IEnumerator<T> связано с тем, что он расширяет интерфейс
IDisposable, поэтому вы должны предоставить метод Dispose(). Оператор foreach в C#
1 уже вызывает метод Dispose() на итераторе, если он реализует интерфейс IDisposable, од-
нако в C# 2 никакие проверки времени выполнения не требуются — если компилятор обнаружит,
что был реализован интерфейс IEnumerable<T>, он предусмотрит безусловный вызов метода
Dispose() в конце цикла (в блоке finally). Многим итераторам в действительности не нужно
что-либо освобождать, но полезно знать, что когда это требуется, то самый распространенный
способ работы с итератором (оператор foreach Î) поддерживает освобождение автоматически.
Чаще всего это используется для освобождения ресурсов после завершения итерации. Например,
итератор может читать строки из файла и должен закрыть файловый дескриптор, когда вызываю-
Глава 3. Параметризованная типизация с использованием обобщений 119
...
DemonstrateTypeof<int>();
Большая часть кода в листинге 3.11 функционирует так, как и можно было ожидать, но стоит
обратить внимание на два аспекта. Во-первых, взгляните на синтаксис, используемый для полу-
чения определения обобщенного типа Dictionary<TKey,TValue>. Занятая в угловых скобках
требуется для того, чтобы сообщить компилятору о необходимости поиска типа с двумя пара-
метрами типов; вспомните, что можно объявить несколько обобщенных типов с одним и тем же
именем при условии, что они будут отличаться по количеству своих параметров типов. Анало-
гично, определение обобщенного типа для MyClass<T1,Т2,Т3,Т4> извлекается посредством
typeof(MyClass<,,,>). Количество параметров типов указывается в коде IL (и в полных име-
нах типов внутри инфраструктуры) за счет помещения после первой части имени типа символа
обратного апострофа и числа. Параметры типов задаются в квадратных, а не в угловых скоб-
ках. Например, вторая строка вывода заканчивается на List'1[Т], показывая, что имеется один
параметр типа, а третья строка включает Dictionary'2[ТКеу,TValue].
Во-вторых, обратите внимание, что везде, где применяется параметр типа метода (X), во время
выполнения используется действительное значение аргумента типа. Поэтому строка Ê выводит
List'1[System.Int32], а не List'1[X], как вы, возможно, ожидали8 . Другими словами, тип,
являющийся открытым на этапе компиляции, во время выполнение может быть закрытым. Все это
слишком запутанно. Если ожидаемые результаты не получены, то вы должны знать об этом,
но в противном случае ни о чем беспокоиться не придется. Получение по-настоящему открытого
сконструированного типа во время выполнения требует немного большей работы. В документации
MSDN по типу Туре.IsGenericType (http://msdn.microsoft.com/en-us/library/
system.type.isgenerictype.aspx) приведен довольно запутанный пример.
Для справки ниже показан вывод кода из листинга 3.11:
System.Int32
System.Collections.Generic.List'1[T]
System.Collections.Generic.Dictionary'2[TKey,TValue]
System.Collections.Generic.List'1[System.Int32]
System.Collections.Generic.Dictionary'2[System.String,System.Int32]
System.Collections.Generic.List'1[System.Int64]
System.Collections.Generic.Dictionary'2[System.Int64,System.Guid]
После получения над объектом, представляющим обобщенный тип, можно проделать множе-
ство действии. Все доступные ранее действия (нахождение членов типа, создание экземпляра и
т.д.) по-прежнему возможны (хотя некоторые из них неприменимы к определениям обобщенных
типов), и вдобавок существуют новые действия, которые позволяют исследовать обобщенную при-
роду типа.
В классе System.Туре имеется слишком много новых методов и свойств, чтобы всех их можно
было рассмотреть здесь подробно, но следующие два метода особенно важны: GetGenericType
Definition() и MakeGenericType(). Они фактически являются противоположностями: пер-
вый действует на сконструированном типе, извлекая определение обобщенного типа, а второй —
на определении обобщенного типа, возвращая сконструированный тип. Вероятно, было бы яснее,
если бы второй метод назывался ConstructType(), MakeConstructedType() либо носил еще
8
Я сознательно нарушил соглашение о применении параметра типа под названием Т именно для того, чтобы можно
было указать на разницу между Т в объявлении List<T> и X в объявлении метода.
Глава 3. Параметризованная типизация с использованием обобщений 121
какое-нибудь имя, содержащее в себе слово Construct или Constructed, но мы должны довольство-
ваться тем, что имеем.
Как и у нормальных типов, для каждого отдельного типа предусмотрен только один объект
Туре, поэтому двукратный вызов метода MakeGenericType() с передачей ему одних и тех же
типов в качестве аргументов каждый раз возвратит ту же самую ссылку. Подобным же образом,
вызовы метода GetGenericTypeDefinition() на двух типах, сконструированных из одного и
того же определения обобщенного типа, дадут тот же самый результат в обоих случаях, даже
если сконструированные типы отличаются (например, List<int> и List<string>).
Полезно также исследовать два других метода, на этот раз уже существующих в .NET 1.1 —
Type.GetType(string) и связанный с ним Assembly.GetType(string); они оба предо-
ставляют динамический эквивалент операции typeof. Вы могли ожидать, что каждую строку
вывода кода из листинга 3.11 можно было бы передать методу GetType(), вызванному на под-
ходящей сборке, однако в реальности, к сожалению, все не так просто. В случае закрытых скон-
струированных типов это сработает — нужно просто поместить аргументы типов в квадратные
скобки. Тем не менее, для определений обобщенных типов понадобится удалить квадратные скоб-
ки вообще — иначе метод GetType() сочтет, что вы имели в виду тип массива. В листинге 3.12
все эти методы демонстрируются в действии.
Console.WriteLine(closedByMethod == closedByName);
Console.WriteLine(closedByName == closedByTypeof);
Console.WriteLine(defByMethod == defByName);
Console.WriteLine(defByName == defByTypeof);
Вывод, получаемый в результате выполнения кода из листинга 3.12, содержит четыре значения
True, подтверждая, что несмотря на получение ссылки на определенный объект типа, существует
только один такой объект.
Как упоминалось ранее, в классе Туре появилось много новых методов и свойств, таких как
GetGenericArguments(), IsGenericTypeDefinition и IsGenericType. Опять-таки, до-
кументация по свойству IsGenericType является, пожалуй, лучшей отправной точкой для даль-
нейших исследований.
Глава 3. Параметризованная типизация с использованием обобщений 122
Обобщенные методы имеют похожий (хотя и меньшего размера) набор дополнительных свойств
и методов. В листинге 3.13 представлена краткая демонстрация этого на примере вызова обоб-
щенного метода с помощью рефлексии.
Спасение в лице C# 4
Соглашусь с тем, что все это выглядит как полнейший беспорядок. К счастью, во многих случаях
на выручку приходит динамическая типизация С#, беря на себя большую часть работы по ре-
флексии обобщений. Она не помогает абсолютно во всех ситуациях, поэтому полезно быть в курсе
общей идеи приведенного ранее кода, однако там, где динамическая типизация применима, она
обеспечивает великолепные результаты. Динамическая типизация будет подробно рассматриваться
в главе 14.
В классе MethodInfo доступно множество других методов и свойств. Хорошей отправной точ-
кой может послужить свойство IsGenericMethod, описанное в документации MSDN по адресу
http://msdn.microsoft.com/en-gb/library/system.reflection.methodinfо.isgen
ericmethod.aspx. Надеюсь, что в этом разделе было предоставлено достаточно информации для
Глава 3. Параметризованная типизация с использованием обобщений 123
успешного начала работы и указаны дополнительные сложности, которые невозможно было преду-
гадать, впервые приступая к доступу к обобщенным типам и методам посредством рефлексии.
На этом рассмотрение дополнительных возможностей завершено. Просто повторюсь: эта глава
никоим образом не претендует на то, чтобы служить полным руководством по обобщениям, но
большинству разработчиков вряд ли понадобится знать все мелкие детали. Я надеюсь, что к
вам это тоже относится, т.к. чем глубже приходится вникать в спецификации, тем труднее их
становится читать. Помните, что если только вы не занимаетесь разработкой самостоятельно и
только для себя, то вряд ли будете единственным, кто работает с вашим кодом. Если вам нужны
средства, более сложные по сравнению с продемонстрированными здесь, вы должны взять на себя
ответственность за то, что любому разработчику, читающему ваш код, потребуется помощь в его
понимании. С другой стороны, если обнаружится, что ваши коллеги не разбираются в некоторых
темах, раскрытых до сих пор, не стесняйтесь рекомендовать им настоящую книгу...
В последнем основном разделе этой главы описаны недостатки обобщений в С#, а также
аналогичные возможности в других языках.
сти основаны на других новых средствах версии C# 3, в числе которых LINQ. Тема вариантности
и сама по себе достаточно сложна, поэтому прежде чем касаться ее, имеет смысл подождать, пока
вы хорошо не освоите оставшийся материал по версиям C# 2 и C# 3. Ради удобства чтения в
настоящем разделе не будут указываться особенности, которые несколько отличаются в C# 4. Все
это прояснится в главе 13.
Предположим, что есть два класса, Turtle и Cat, оба производные от абстрактного класса
Animal. В приведенном ниже примере код с массивом (слева) является допустимым кодом C# 2,
а код с обобщениями (справа) — нет.
В обоих случаях компилятор без проблем воспримет вторые строки, но первая строка кода,
показанного справа, вызывает следующую ошибку:
Получив ответ на вопрос о том, почему обобщения инвариантны, следующий очевидный вопрос
касается причины ковариантности массивов. Согласно книге Джеймса Миллера и Сьюзен Регсдейл
Common Language Infrastructure Annotated Standard (Addison-Wesley Professional, 2003 г.), в пер-
вой версии .NET проектировщики хотели охватить как можно более широкую аудиторию, включая
тех, кому нужна возможность запуска кода, скомпилированного из исходного кода Java. Другими
словами, массивы .NET являются ковариантными из-за ковариантности массивов Java — несмотря
на то, что это известный недостаток Java.
Глава 3. Параметризованная типизация с использованием обобщений 125
Итак, вы узнали причины, по которым дела обстоят именно так, как есть, но почему вы долж-
ны об этом беспокоиться и как обойти указанное ограничение?
Приведенный ранее пример со списком содержал очевидную проблему. Можно добавлять эле-
менты в список, но в таком случае теряется безопасность типов, и операция Add() является
примером значения, используемого в качестве входного при обращении к API-интерфейсу: это
значение предоставляет вызывающий код. Что произойдет, если ограничиться только выводом
значений?
Очевидными примерами могут служить интерфейс IEnumerator<T> и связанный с ним ин-
терфейс IEnumerator<Т>. В действительности они представляют собой едва ли не канонические
примеры обобщенной ковариантности. Вместе эти интерфейсы описывают последовательность зна-
чений; все, что известно о значениях — это то, что каждое из них будет совместимым с Т,
следовательно, всегда можно записать так:
Т currentValue = iterator.Current;
<<interface>> <<interface>>
IShape IDrawing
+Shapes: IEnumerable<IShape>
Circle Rectangle
MondrianDrawing SeuratDrawing
+rectangles: List<Rectangle> +circles: List<Circle>
Но для каждого типа чертежа, возможно, будет проще внутренне поддерживать более строго
типизированный список. Например, тип SeuratDrawing может включать поле типа List<Circle>.
Это удобнее, чем иметь поле типа List<IShape>, т.к. если необходимо манипулировать кружоч-
ками специфическим для них образом, то не придется использовать приведение.
При наличии ноля List<IShape> можно было бы либо возвратить его значение непосред-
ственно, либо поместить его внутрь ReadOnlyCollection<IShape>, предотвращая вызываю-
щий код от применения приведения — в любом случае реализация будет простой и недорогой
в плане затрат. Однако сделать это не получится, когда тины не совпадают. Преобразование из
IEnumerable<Circle> в IEnumerable<IShape> невозможно. А что можно сделать?
Существует несколько вариантов, которые перечислены ниже.
• Изменить тип поля на List<IShape> и пользоваться приведениями. Это не особенно удобно
и во многом нивелирует смысл применения обобщений.
• Сделать каждую реализацию свойства Shapes создающей новую копию списка, возможно,
с применением List<T>.ConvertAll для простоты. Так или иначе, создание независи-
мой копии коллекции часто является правильным решением в API-интерфейсе, однако оно
приводит к большому объему копирования, которое во многих случаях может оказаться
нежелательным.
НЕПРАВИЛЬНО
circles.Sort(areaComparer);
Тем не менее, этот код работать не будет, т.к. метод Sort() в List<Circle> на самом
деле получает IComparer<Circle>. Тот факт, что тип AreaComparer может сравнивать лю-
бые формы, а не просто кружочки, никакого влияния на компилятор не оказывает. Компилятор
расценивает типы IComparer<Circle> и IComparer<IShape> как совершенно разные. С ума
сойти, не так ли? Хорошо, если бы вместо этого метод Sort() имел такую сигнатуру:
void Sort<S>(IComparer<S> comparer) where T : S
К сожалению, сигнатура метода Sort() не только другая, но она и не может быть такой —
ограничение недопустимо, т.к. оно сделано на Т, а не на S. Необходимо ограничение преобразова-
ния типа, но в обратном направлении, указывающее, что тип S должен находиться где-то выше, а
не ниже в дереве наследования Т.
Учитывая невозможность этого, как можно поступить? Теперь вариантов меньше. Первый ва-
риант — возврат к идее создания обобщенного вспомогательного класса, как показано в листинге
3.14.
{
private readonly IComparer<TBase> comparer; ❷ Запоминание исходного компаратора
10
В главе 13 вы увидите, что есть несколько больше условий, однако так выглядит общий принцип.
Глава 3. Параметризованная типизация с использованием обобщений 128
}
}
Это пример шаблона проектирования “Адаптер” в действии, хотя в нем вместо подгонки одно-
го интерфейса к совершенно другому интерфейсу осуществляется просто адаптация IComparer
<TBase> к IComparer<TDerived>. Мы запоминаем исходный компаратор Ë, предоставляю-
щий действительную логику для сравнения элементов базового типа, после чего обращаемся к
нему, когда требуется сравнение элементов производного типа Ì. Факт отсутствия приведений
(даже скрытых) должен вселить определенную уверенность: этот вспомогательный класс полно-
стью безопасен в отношении типов. Возможность обращения к базовому компаратору существует
благодаря доступности неявного преобразования из TDerived в TBase, которое затребовано с
помощью ограничения типа Ê.
Второй вариант предусматривает превращение класса для сравнения площадей в обобщенный
класс с ограничением преобразования типа, чтобы он позволял сравнивать любые два значения
одного и того же типа при условии реализации этим типом интерфейса IShape. Для простоты в
ситуации, когда такая функциональность не нужна, можно было бы оставить класс необобщенным,
унаследовав его от обобщенного класса:
Конечно, это можно делать только когда есть возможность изменения кода класса для сравне-
ния. Хотя решение достаточно эффективно, выглядит оно по-прежнему неестественно — почему
компаратор должен конструироваться для разных типов по-разному, если он не собирается вести
себя по-другому? Зачем наследовать от класса, если в действительности поведение не специали-
зируется?
Обратите внимание, что различные возможности для ковариантности и контравариантности
предполагают интенсивное использование обобщений и ограничений для выражения интерфейса
в более общей манере или для предоставления обобщенных вспомогательных классов. Я пони-
маю, что добавление ограничения приводит к тому, что интерфейс кажется менее общим, однако
обобщенность добавляется, прежде всего, за счет превращения типа или метода в обобщенный.
Когда вы столкнетесь с проблемой вроде описанной, добавление куда-либо уровня обобщенности
с соответствующим ограничением должно быть первым рассматриваемым вариантом. Здесь ча-
сто полезны обобщенные методы (в отличие от обобщенных типов), т.к. выведение типов может
сделать отсутствие вариантности невидимым невооруженному глазу. Это особенно справедливо в
языке C# 3, который обладает более развитыми возможностями выведения типов, чем C# 2.
Такой недостаток является очень частой причиной поднятия вопросов на дискуссионных пло-
щадках С#. Оставшиеся проблемы либо относительно академичны, либо касаются только неболь-
шой части сообщества разработчиков. Следующая проблема главным образом затрагивает тех, кто
при своей работе выполняет множество расчетов (обычно научных или финансовых).
НЕПРАВИЛЬНО
sum += datum;
count++;
}
return sum / count;
}
Очевидно, что этот код никогда не заработал бы для всех типов данных — например, что могло
бы означать сложение двух экземпляров класса Exception? Ясно, что существует определенное
ограничение на то, каким образом можно выражать необходимые действия: суммировать экзем-
пляры Т и делить Т на целое число. Если бы можно было записывать код, подобный показанному
выше, пусть даже только для встроенных типов, появилась бы возможность реализовывать обоб-
щенные алгоритмы, в которых не играет роли, с какими типами данных они работают — int,
long, double, decimal и т.д.
Ограничение только встроенными типами, конечно, не радовало бы, но это все же лучше,
чем ничего. Идеальное решение также позволяло бы пользовательским типам выступать в каче-
стве числовых, чтобы можно было определить тип Complex для поддержки комплексных чисел,
например11 .
Затем тип комплексного числа мог бы хранить все свои компоненты также и обобщенным
путем, позволяя иметь Complex<float>, Complex<double> и т.д.
Представляются возможными два связанных (но гипотетических) решения. Одно из них могло
бы разрешить применение ограничений на операциях, чтобы можно было устанавливать набор
ограничений, подобный приведенному ниже (в данный момент неправильному):
Это требует, чтобы тип Т имел операции, используемые в показанном ранее коде. Другое ре-
шение предполагало бы определение нескольких операций и, возможно, преобразований, которые
должны поддерживаться типом для удовлетворения дополнительного ограничения — его можно
было бы сделать “числовым ограничением”, записав where Т : numeric.
С обоими решениями связана одна проблема — они не могут быть представлены как нормаль-
ные интерфейсы, потому что перегрузка операций реализуется с помощью статических членов,
которые нельзя применять для реализации интерфейсов. Я нахожу привлекательной идею ста-
тических интерфейсов, т.е. интерфейсов, в которых объявлены только статические члены, в том
числе методы, операции и конструкторы. Статические интерфейсы подобного рода были бы удобны
только в рамках ограничений типов, однако они предоставляют безопасный к типам обобщенный
способ доступа к статическим членам. Тем не менее, все это витание в облаках (дополнительные
сведения по данной теме можно найти в моем блоге: http://mng.bz/3Rk3). Мне ничего не
известно о планах по включению статических интерфейсов в будущую версию С#.
11
Конечно, здесь предполагается, что вы не работаете с .NET 4 или последующей версией, потому что тогда можно
было бы воспользоваться структурой System.Numerics.Complex.
Глава 3. Параметризованная типизация с использованием обобщений 130
Двумя самыми ясными способами обхода этой проблемы на сегодняшний день требуют более
поздних версий .NET. Первый способ разработан Марком Гревеллом (http://yoda.arachsys.
com/csharp/genericoperators.html) и предполагает использование деревьев выражений
(которые будут представлены в главе 9) для построения динамических методов: второй способ
предусматривает применение средств C# 4. Пример использования второго способа будет приве-
ден в главе 14. Но, как можно понять по описаниям, оба способа являются динамическими, т.е.
чтобы выяснить, работает ли код с отдельно взятым типом, придется подождать до времени выпол-
нения. Существует несколько обходных путей, в которых по-прежнему применяется статическая
типизация, но им присущи другие недостатки (как ни странно, иногда они могут быть медленнее,
чем динамический код).
Два недостатка, рассмотренные до сих пор, были достаточно реальными — они отражали
проблемы, которые могли возникать во время действительной разработки. Если вам интересно,
можете задаться также вопросом о других недостатках, которые не обязательно замедляют раз-
работку, но просто любопытны сами по себе. В частности, почему обобщения охватывают только
типы и методы?
Надеюсь, вы согласитесь с тем, что все это выглядит слегка нелепо. Финализаторы даже нельзя
вызывать явно в коде С#, поэтому строка для них отсутствует. Насколько я вижу, невозможность
сделать ничего из показанного выше кода не создает значительных проблем — просто полезно
знать об этом как об академическом недостатке.
Пожалуй, больше всего это ограничение раздражает применительно к конструктору. Однако
хорошим обходным способом решения данной проблемы является статический обобщенный метод
в классе, а приведенный ранее пример синтаксиса обобщенного конструктора с двумя списками
аргументов типов выглядит ужасно.
Это никоим образом не единственные недостатки обобщений С#, но я уверен, что именно с
ними вы, скорее всего, столкнетесь в повседневной работе, при обсуждениях в сообществе или
при неспешном исследовании средства как единого целого. В следующих двух разделах будет по-
казано, что определенные аспекты из числа рассмотренных ранее не являются проблемами в двух
других языках, функциональные возможности которых чаще всего сравнивают с обобщениями
С#: C++ (с шаблонами) и Java (с обобщениями в версии Java 5). Начнем с языка C++.
Глава 3. Параметризованная типизация с использованием обобщений 131
The C++ Programming Language, 3-е изд. (Addison-Wesley Professional, 1997 г.). Читать эту
книгу не всегда легко, но глава, посвященная шаблонам, написана довольно понятно (как только
вы освоитесь с терминологией и синтаксисом C++).
Больше случаев сравнения с обобщениями .NET можно найти в статье от команды создате-
лей Visual C++ по адресу http://blogs.msdn.com/b/branbray/archive/2003/11/19/
51023.aspx.
Другим очевидным языком для сравнения с C# в смысле обобщений является Java, в котором
это средство появилось в выпуске 1.512 через несколько лет после того, как другие проекты
привели к созданию Java-подобных языков, поддерживающих обобщения.
Оба фрагмента генерируют одинаковый байт-код Java кроме последней строки, которая явля-
ется допустимой в необобщенном случае, но вызовет ошибку на этапе компиляции в обобщенной
версии кода. Обобщенный тип можно использовать как низкоуровневый, что подобно применению
java.lang.Object для каждого аргумента типа. Такое переписывание — и утеря информа-
ции — называется стиранием типов. В Java отсутствует понятие типов значений, определяемых
пользователем, и в качестве аргументов типов нельзя указывать даже встроенные типы значений.
Вместо этого придется применять упакованные версии — к примеру, ArrayList<Integer> для
списка целых чисел.
Все это может показаться неутешительным по сравнению с обобщениями С#, но обобщения
Java обладают также и рядом интересных особенностей.
• Для использования обобщений Java изучать какой-то новый набор классов не понадобится —
там, где при отказе от обобщений применялся бы тип ArrayList, в случае обобщений
просто используется тип ArrayList<E>. Существующие классы довольно легко могут быть
модернизированы обобщенными версиями.
По моему мнению, обобщения .NET более совершенны почти во всех отношениях, хотя когда
я сталкиваюсь с проблемами ковариантности/контравариантности, то часто жалею, что у меня
нет групповых символов. Положение дел в какой-то мере улучшает ограниченная обобщенная
вариантность версии C# 4, однако по-прежнему существуют ситуации, при которых модель ва-
риантности Java работает лучше. Хотя язык Java с обобщениями намного лучше языка Java без
обобщений, никакого выигрыша в плане производительности обобщения не дают, а безопасность
типов поддерживается только на этапе компиляции.
3.6 Резюме
Уф! Обобщения гораздо легче использовать в реальности, чем описать их. Хотя обобщения
могут стать сложными, они рассматриваются как самое важное дополнение C# 2 и невероятно
удобны. Более того, если когда-либо после написания кода с обобщениями вам придется возвра-
титься снова к C# 1, вам будет крайне не хватать их. (К счастью, это становится все менее
вероятным.)
В этой главе я не пытался раскрыть каждую деталь о том, что при работе с обобщениями
доступно, а что нет — это забота спецификации языка, которая предназначена для сухого изло-
жения фактов. Вместо этого я избрал практический подход, предоставляя информацию, которая
будет необходима для повседневного использования, с редкими вкраплениями теории ради чисто
академического интереса.
Были показаны три основных преимущества обобщений: безопасность типов периода компиля-
ции, производительность и выразительность кода. Возможность обеспечить раннюю проверку кода
с помощью IDE-среды и компилятора определенно удобна, но более чем спорно считать, что мож-
но получить большую выгоду от инструментов, которые интеллектуальным образом предлагают
варианты на основе применяемых типов, нежели от действительного аспекта безопасности.
Производительность увеличивается наиболее радикально, когда дело доходит до типов значе-
ний, которые больше не должны упаковываться и распаковываться, когда они используются в
строго типизированных обобщенных API-интерфейсах. Особенно это касается обобщенных типов
коллекций в .NET 2.0. Производительность при работе со ссылочными топами обычно улучшается,
но незначительно.
Глава 3. Параметризованная типизация с использованием обобщений 134
С применением обобщений код способен выражать свои намерения более ясно — вместо ком-
ментария или длинного имени переменной, требуемого для точного описания участвующих типов,
эту работу .могут сделать подробности самого типа. Комментарии и имена переменных со вре-
менем могут стать неточными из-за того, что в них забыли внести изменения при модификации
кода, а информация о типе корректна по определению.
Обобщения не позволяют делать абсолютно все, что заблагорассудится, и в этой главе были
раскрыты некоторые их недостатки, но если вы действительно изберете С# 2 и обобщенные типы
внутри .NET 2.0 Framework, то найдете невероятное множество случаев их использования в своем
коде.
Обобщения то и дело будут упоминаться в последующих главах, поскольку на них основаны
другие новые средства. И действительно, не будь обобщений, тема следующей главы была бы
совершенно иной — мы рассмотрим типы, допускающие null, как это реализовано с помощью
Nullable<T>.
ГЛАВА 4
В этой главе...
Концепция значений null была темой обсуждений на протяжении многих лет. Является ли
ссылка null значением или же она указывает на отсутствие значения? Считать ли, что “ничего” —
это “что-нибудь”? Должны ли языки вообще поддерживать концепцию значений null или она
должна быть представлена с помощью других моделей?
В этой главе я постараюсь придерживаться практической стороны, а не философской. Сначала
мы посмотрим, почему данная проблема возникла в принципе — почему в C# 1 нельзя установить
в null переменную типа значения, и каковы были традиционные альтернативы. В конце концов, я
представлю вам нашего рыцаря в сияющих доспехах — тип System.Nullable<T> — и затем мы
ознакомимся с тем, как версия C# 2 обеспечивает простоту и лаконичность кода при работе с ти-
пами, допускающими null. Подобно обобщениям, допускающие null типы иногда используются
в ситуациях, которые выходят за рамки ожидаемых, и в конце главы мы рассмотрим несколько
примеров таких случаев.
Итак, когда значение не является значением? Давайте узнаем.
Этот вопрос возникает вполне естественно — примером может служить приложение электрон-
ной коммерции, в котором пользователи могут просматривать хронологию движения средств на
своих счетах. Если заказ был размещен, однако еще не доставлен, может присутствовать дата по-
купки, но отсутствовать дата отправки. Каким же образом выразить это в типе, предназначенном
для представления детальных сведений о заказе?
До появления C# 2 ответ на поставленный выше вопрос обычно состоял из двух частей:
прежде всего, давалось объяснение невозможности применения null, а затем приводился список
доступных вариантов. В настоящее время ответ обычно содержит только объяснение типов, до-
пускающих null, однако полезно взглянуть на варианты в C# 1, чтобы лучше понять, откуда
происходит проблема.
данных, как правило, поддерживается значение NULL для каждого типа (если только поле специ-
ально не сделано не допускающим NULL), поэтому допускать значение null могут символьные
данные, целые числа, булевские значения — словом, все, что угодно. При выборке данных из базы
обычно нежелательно терять информацию, так что важно иметь возможность каким-то образом
представлять значения null в читаемых данных.
Тем временем, вопрос переходит на следующий уровень. Почему в базах данных разрешены
значения null для дат, целых чисел и тому подобного? Как правило, значения null используют-
ся для неизвестных или отсутствующих значений, таких как дата отправки в ранее упомянутом
примере приложения электронной коммерции. С помощью null представляется отсутствие точной
информации, что может оказаться важным во многих ситуациях. На самом деле типы значений,
допускающие null, удобны не только при взаимодействии с базами данных: просто это сцена-
рий, в котором разработчики обычно впервые сталкиваются с проблемой. Это подводит нас к
ознакомлению с вариантами представления значений null в C# 1.
программисты (включая меня) достаточно осторожно относятся к этим значениям, как и должно
быть, и это еще одно свидетельство изъянов данного шаблона.
В инфраструктуре ADO.NET имеется разновидность этого шаблона, при котором одно и то же
“магическое” значение DBNull.Value используется для всех значений null, независимо от типа.
В таком случае вводилось дополнительное значение, и даже дополнительный тип, для указания
на ситуацию, когда база данных возвращает null. Однако это применимо только в случаях, когда
безопасность типов на этапе компиляции не важна (другими словами, когда вполне устраивает
применение типа object и выполнение приведения после проверки на предмет null), и снова
такой подход не выглядит правильным. В действительности он представляет собой смесь шаблона
с “магическим” значением и шаблона с оболочкой ссылочного типа, который рассматривается сле-
дующим.
Второе решение может принимать две формы. Простая форма предусматривает использование
object для типа переменной, а также упаковку и распаковку значений по мере необходимо-
сти. Более сложная (и более привлекательная) форма заключается в создании для каждого типа
значения, который нуждается в поддержке null, ссылочного типа, содержащего единственную
переменную экземпляра этого типа значения и операции неявного преобразования в тип значения
и из него. Все это можно было бы сделать в одном обобщенном типе, но если уж применяется
C# 2, то взамен можно было бы воспользоваться типами, допускающими null, которые описаны
в настоящей главе. Если же вы придерживаетесь C# 1, то должны написать дополнительный
исходный код для каждого типа, которому необходима оболочка. Прием несложно воспроизве-
сти в виде шаблона для автоматического генерации кода, но все равно это порождает накладные
расходы, которых по возможности лучше избегать.
Обеим формам второго шаблона присуща проблема: хотя они позволяют применять значение
null напрямую, обе формы требуют создания объектов в куче, что может привести к повышенной
нагрузке на сборщик мусора, если данный подход нужно использовать часто, и увеличению расхо-
да памяти на создаваемые объекты. В более сложном решении можно было бы сделать ссылочный
тип изменяемым, что позволило бы сократить количество экземпляров, подлежащих созданию, но
также привело бы к получению менее интуитивно понятного кода.
Теперь, когда вы знаете необходимые свойства, давайте посмотрим, как можно создать экзем-
пляр этого типа. Тип Nullable<T> имеет два конструктора: стандартный (создающий экземпляр
без значения) и конструктор, получающий в качестве значения экземпляр T. После конструирова-
ния экземпляр является неизменяемым.
Говорят, что тип неизменяемый, если он спроектирован так, что его экземпляр не может быть
изменен после конструирования. Неизменяемые типы часто приводят к получению более ясно-
го проектного решения, когда необходимо отслеживать то, что могло бы изменять разделяемые
значения — особенно в многопоточном приложении.
Неизменяемость в особенности важна для типов значений; они должны быть неизменяемыми
почти всегда. Большинство типов значений в инфраструктуре являются неизменяемыми, но суще-
ствует ряд распространенных исключений — в частности, структуры Point в Windows Forms и
Windows Presentation Foundation спроектированы как изменяемые.
Если вам необходим какой-то способ базирования одного значения на другом, последуйте при-
меру типов DateTime и TimeSpan — предоставьте методы и операции, которые возвращают
новое значение, а не модифицируют существующее. Это позволит избежать тонких ошибок всех
видов, включая ситуации, при которых кажется, что производится изменение чего-либо, но в дей-
ствительности изменяется копия. Просто скажите “нет” изменяемым типам значений.
Упаковка и распаковка
Прежде чем двигаться дальше, давайте взглянем на все это в действии. В листинге 4.1 демон-
стрируется все то, что можно делать с помощью типа Nullable<T> напрямую, оставив пока в
стороне метод Equals().
{
Console.WriteLine("HasValue: {0}", x.HasValue);
if (x.HasValue)
{
Console.WriteLine("Value: {0}", x.Value);
Console.WriteLine("Explicit conversion: {0}", (int)x);
}
Console.WriteLine("GetValueOrDefault(): {0}",
x.GetValueOrDefault());
Console.WriteLine("GetValueOrDefault(10): {0}",
x.GetValueOrDefault(10));
Console.WriteLine("ToString(): \"{0}\"", x.ToString());
Console.WriteLine("GetHashCode(): {0}", x.GetHashCode());
Console.WriteLine();
}
...
Nullable<int> х = 5;
❷ Создание оболочки для значения 5
х = new Nullable<int>(5);
Console.WriteLine("Instance with value:");
Display(x);
В листинге 4.1 продемонстрированы два разных способа (в терминах исходного кода С#) по-
мещения в оболочку значения базового типа Ë и применение разнообразных членов экземпляра
Nullable<int> Ê. Затем создается экземпляр, не имеющий значения Ì, для которого исполь-
зуются те же самые члены в том же порядке, но без свойства Value и явного преобразования в
int, т.к. они привели бы к генерации исключения. Ниже показан вывод кода в листинге 4.1:
Nullable<int>
hasValue
false
Упаковка
Ссылка null
value
0
Nullable<int>
Упакованное
hasValue значение int
true Упаковка Ссылка
5
value
5
Рис. 4.2. Результаты упаковки экземпляра без значения (вверху) и со значением (внизу)
Nullable<int> nullable = 5;
В выводе кода из листинга 4.2 указано, что типом упакованного значения является System
.Int32 (не System.Nullable<System.Int32>). Это подтверждает возможность извлечения
значения за счет распаковки либо в int, либо в Nullable<int>. Наконец, вывод показывает,
что можно упаковать экземпляр типа, допускающего null, без значения в ссылку null и затем
успешно распаковать его в другой экземпляр типа, допускающего null, без значения. Если по-
пытаться распаковать последнее значение переменной boxed в тип int, не допускающий null,
возникло бы исключение NullReferenceException.
Разобравшись с поведением упаковки и распаковки, можно приступать к анализу поведения
метода Nullable<T>.Equals().
Обратите внимание, что вы не обязаны рассматривать случай, когда переменная second от-
носится к другому типу Nullable<T>, т.к. правила упаковки запрещают эту ситуацию. Типом
second является object, поэтому для превращения его в Nullable<T> понадобится упаковка,
а как только что было показано, упаковка экземпляра типа, допускающего null, создает упаковку
типа, не допускающего null, или возвращает ссылку null. Поначалу первое правило может вы-
глядеть как нарушение контракта для object.Equals(object), который требует, чтобы вызов
х.Equals(null) возвращал false — но только тогда, когда х представляет собой ссылку, от-
личную от null. Опять-таки, из-за поведения упаковки в отношении реализации Nullable<T>
никогда не возникнет обращения через ссылку.
По большей части эти правила совместимы с правилами эквивалентности, применяемыми в
.NET повсюду, поэтому экземпляры типов, допускающих null, можно использовать в качестве
ключей в словарях и любых других ситуациях, когда необходима поддержка эквивалентности. Но
только не ожидайте, что эквивалентность будет проводить различие между экземпляром типа,
не допускающего null, и экземпляром типа, допускающего null, со значением — поддержка
эквивалентности настроена так, что эти два случая трактуются как одинаковые.
Приведенный выше материал охватывает саму структуру Nullable<T>, но у нее имеется не
очень ясный партнер: класс Nullable.
Если параметр относится к типу, допускающему null, метод возвращает его базовый тип; в
противном случае возвращается null. Причина того, что метод не определен как обобщенный,
2
Статические классы более подробно описаны в главе 7.
Глава 4. Типы, допускающие значения null 145
заключается в том, что если бы базовый тип был известен с самого начала, то вызывать бы этот
метод не пришлось.
Теперь вы знаете, как инфраструктура и среда CLR предоставляют поддержку типов, допус-
кающих null, тем не менее, в C# 2 были добавлены языковые средства, позволяющие удобно
работать с ними.
Имея все это в виду, давайте посмотрим, какие средства предлагает версия C# 2, начиная с
сокращения беспорядка в коде.
4.3.1 Модификатор ?
Существует ряд элементов синтаксиса, которые на первых порах могут оказаться незнакомыми,
но относительно которых имеется интуитивное понимание того, как они работают. Одним из
таких элементов для меня является условная операция (а ? b : с) — она ставит вопрос и затем
Глава 4. Типы, допускающие значения null 146
Листинг 4.3. Код, эквивалентный коду в листинге 4.2, но в котором применяется модифика-
тор ?
int? nullable = 5;
nullable = (int?)boxed;
Console.WriteLine(nullable);
nullable = (int?)boxed;
Console.WriteLine(nullable.HasValue);
Не имеет смысла объяснять, что и как делает этот код, поскольку результат его выполнения
будет в точности таким же, как у кода из листинга 4.2. Код из обоих листингов компилируется
в тот же самый код IL — в этих листингах просто используется разный синтаксис, почти так же,
как тип int взаимозаменяем с типом System.Int32. Сокращенную версию можно применять
повсюду, включая сигнатуры методов, выражения typeof, приведения и тому подобное.
Причина, по которой я считаю модификатор ? удачным выбором, связана с тем, что он придает
природе переменной атмосферу неопределенности. Имеет ли переменная nullable в листинге
4.3 целочисленное значение? Действительно, в любой момент времени она может иметь такое
значение, но может быть и null.
Начиная с этого места, модификатор ? будет использоваться во всех примерах — это более
лаконично и, пожалуй, является идиоматическим способом работы с типами, допускающими null,
в С#. Но если вам кажется, что этот модификатор очень легко пропустить при чтении кода, то нет
никаких препятствий тому, чтобы применять более длинный синтаксис. Просто сравните листинги
в этом и предыдущем разделе и определитесь, какой вариант для вас выглядит более ясным.
Учитывая, что в спецификации C# 2 определено значение null, было бы странно, если бы
не было возможности использовать для его представления литерал null, уже присутствующий в
языке. К счастью, это можно делать...
Глава 4. Типы, допускающие значения null 147
class Person
{
DateTime birth;
DateTime? death;
string name;
{
return DateTime.Now - birth;
}
else
{
return death.Value - birth; ❷ Распаковка в целях вычисления
}
}
}
public Person(string name,
DateTime birth,
DateTime? death)
Глава 4. Типы, допускающие значения null 148
{
this.birth = birth;
this.death = death;
this.name = name;
}
}
...
Person turing = new Person("Alan Turing ",
new DateTime(1912, 6, 23),
new DateTime(1954, 6, 7)); ❸Упаковка DateTime как типа,
допускающего null
Код в листинге 4.4 не производит какой-либо вывод, но тот факт, что он успешно компили-
руется, мог бы удивить вас, если бы вы еще не приступили к чтению этой главы. Помимо того,
что использование модификатора ? вызывает путаницу, может показаться странным и наличие
возможности сравнения экземпляра DateTime? с null или передачи null в качестве аргумента
для параметра типа DateTime?.
К счастью, к этому времени смысл должен быть понятен — когда производится сравнение
переменной death с null, выясняется ответ на вопрос, равно ли значение указанной перемен-
ной значению null. Подобным же образом, когда значение null выступает как экземпляр типа
DateTime?, на самом деле создается значение null для этого типа за счет вызова стандартного
конструктора. И действительно, в коде IL, сгенерированном для листинга 4.4, можно заметить про-
сто обращение к свойству death.HasValue Ê и создание нового экземпляра типа DateTime?
Í с применением стандартного конструктора (что представлено в коде IL с помощью инструкции
initobj). Дата смерти Алана Тьюринга Ì создается путем вызова обычного конструктора типа
DateTime и последующей передачи результата конструктору Nullable<DateTime>, принимаю-
щему параметр.
Как уже упоминалось, просмотр кода IL может оказаться удобным способом выяснения, что в
действительности делает написанный код, особенно если что-то скомпилировалось, в то время как
вы ожидали, что это не должно было произойти. Для этого можно воспользоваться инструментом
ildasm, входящим в состав .NET SDK, либо одним из многих доступных декомпиляторов, таких
как .NET Reflector, ILSpy, dotPeek или JustDecompile. (Когда я ссылаюсь в данной книге на
Reflector, то это лишь потому, что пользуюсь указанным инструментом по привычке. Я уверен,
что другие инструменты в равной степени хороши.)
Вы уже видели, что в C# предоставляется сокращенный синтаксис для концепции значения
null, который позволяет сделать код более выразительным при условии в первую очередь пони-
мания типов, допускающих null. Однако одна часть листинга 4.4 выполняет несколько большую
работу, чем можно было ожидать — вычитание в строке Ë. По какой причине пришлось распа-
ковывать значение? Почему бы просто не возвратить результат death - birth напрямую? Что
бы означало это выражение в случае, если переменная death равна null (хотя такая ситуация в
коде исключается предварительной проверкой death на предмет null)? На все эти, а также многие
другие вопросы будут даны ответы в следующем разделе.
Глава 4. Типы, допускающие значения null 149
• явное преобразование из Т? в Т.
• из S? в Т (всегда явное).
До сих пор все относительно просто. А теперь перейдем к рассмотрению операций, дела с
которым обстоят несколько сложнее.
• эквивалентности: == !=
Когда эти операции перегружаются для типа значения Т, не допускающего null, то тип Т?,
допускающий null, получает те же самые операции со слегка отличающимися типами операн-
дов и результатов. Они называются поднятыми операциями независимо от того, являются ли
предварительно определенными, такими как сложение для числовых типов, или определенными
пользователем, вроде сложения TimeSpan и DateTime. Во время применения этих операций
имеется несколько ограничений, которые перечислены ниже.
• Операции true и false никогда не поднимаются. Хотя с учетом их крайне редкого исполь-
зования, потеря невелика.
• Для операций эквивалентности и отношения возвращаемый тип должен быть типом bool.
• Операции & и | для типа bool? поддерживают отдельно определенное поведение, как будет
показано в листинге 4.3.4.
Для всех операций типы операндов становятся их эквиваленты допускающие null. Для унар-
ных и бинарных операций возвращаемый тип также становится допускающим null, и если любой
из операндов равен null, то возвращается значение null. Операции эквивалентности и отноше-
ния сохраняют свои булевские возвращаемые типы, не допускающие null. При определении экви-
валентности два значения null считаются равными, а значение null и любое значение, отличное
от null — разными, что согласуется с поведением, описанным в разделе 4.2.3. Операции отноше-
ния всегда возвращают false, если один из операндов имеет значение null. Когда операнды не
равны null, очевидным образом выполняется операция типа, не допускающего null.
Все эти правила выглядят сложнее, чем есть на самом деле — по большей части, все работает
вполне предсказуемо. Проще всего увидеть, что происходит, рассмотрев несколько примеров, и
поскольку тип int имеет настолько много предварительно определенных операций (к тому же
целочисленные значения очень легко выражать), он представляется естественным кандидатом
для демонстрации. В табл. 4.1 приведены примеры выражений, сигнатуры поднятых операций
и результаты. При этом предполагается, что определены переменные four, five и nullInt,
каждая из которых относится к типу int? и имеет очевидное значение.
3
Операции эквивалентности и отношения — это также бинарные операции, но их повеление немного отличается
от повеления других операции, из-за чего в списке они указаны по отдельности.
Глава 4. Типы, допускающие значения null 151
При обработке этого кода компилятор C# выдает предупреждения, но вас может удивить, что
такой код вообще разрешен. Здесь происходит вот что: компилятор видит выражение int слева
от операции ==, значение null справа от нее, и знает о существовании неявного преобразования
в тип int? для каждой части выражения. Поскольку сравнение двух значений int? полно-
стью допустимо, код не приводит к генерации ошибки, а только предупреждения. Дополнительное
осложнение связано с тем, что это не разрешено делать в случае, когда вместо int применяет-
ся обобщенный параметр типа, ограниченный типом значения — правила обобщений запрещают
сравнение с null в такой ситуации.
Так или иначе, возникнет ошибка либо предупреждение, и если вы внимательно следите за
предупреждениями, то не должны в конечном итоге получить дефектный код из-за указанной
индивидуальной особенности, и я надеюсь, что мои пояснения помогут вам лучше понять, что
происходит.
Глава 4. Типы, допускающие значения null 152
Теперь можно ответить на вопрос, поставленный в конце предыдущего раздела: почему в ли-
стинге 4.4 используется выражение death.Value - birth, а не просто death - birth? При-
меняя предыдущие правила, можно было бы указать второе выражение, но результат имел бы тип
TimeSpan? вместо TimeSpan. Это вызвало бы необходимость либо привести результат к типу
TimeSpan, используя свойство Value, либо изменить свойство Аgе с целью возвращения типа
TimeSpan?, который всего лишь перемещает проблему в вызывающий код. Это по-прежнему вы-
глядит несколько неуклюже, но в разделе 4.3.6 будет показана более удачная реализация свойства
Age.
В списке ограничений, касающихся поднятия операций, упоминалось, что по сравнению с дру-
гими типами тип bool? работает по-другому. В следующем разделе будут даны соответствующие
пояснения и представлена более широкая картина, иллюстрирующая причины, по которым все эти
операции действуют именно так, а не иначе.
Таблица 4.2. Таблица истинности для логических операций “И”, включающего “ИЛИ”, исключающего
“ИЛИ” и логического отрицания применительно к типу bool?
x y x & у x | у x^у !x
true true true true false false
true false false true true false
true null null true null false
false true false true true true
false false false false false true
false null false null null true
null true null true null null
null false false null null null
null null null null null null
Глава 4. Типы, допускающие значения null 153
Если вам проще понять обоснование правил, чем искать значения в таблицах, то идея заключа-
ется в том, что значение null типа bool? в некотором смысле можно считать как “неопределен-
ность”. Представим, что каждая запись null на входной стороне таблицы является не значением,
а переменной. Тогда на выходной стороне таблицы будет всегда получаться значение null, если
результат зависит от значения этой переменной. Например, рассмотрим третью строку таблицы ис-
тинности. Выражение true & у будет равно true, если у равно true, но выражение true | y
будет равно true всегда, каким бы ни было значение у, поэтому результатами, допускающими
null, являются null и true, соответственно.
При обдумывании поднятых операций и особенно функционирования логики, допускающей
null, проектировщикам языка пришлось иметь дело с двумя несовместимыми друг с другом на-
борами поведенческих аспектов — ссылками null в языке C# 1 и значениями NULL в языке
SQL. Во многих случаях они никак не конфликтуют — поскольку в C# 1 отсутствует концепция
применения логических операций к ссылкам null, проблемы с использованием полученных ра-
нее SQL-подобных результатов не возникали. Тем не менее, показанные ранее определения могут
вызвать удивление у некоторых разработчиков на языке SQL, когда дело дойдет до сравнений. В
стандартном SQL результат сравнения двух значений (на предмет эквивалентности или выяснения
больше/меньше чем) всегда неизвестен, если одним из значений является NULL. В C# 2 результат
никогда не равен null, в частности, два значения null считаются равными друг другу.
Важно помнить, что поднятые операции и преобразования наряду с логикой типа bool?, описанной
в этом разделе, предоставляются компилятором С#, а не средой CLR или самой инфраструктурой.
В результате анализа с помощью ildasm кода, в котором применяется любая из операций, допус-
кающих null, вы обнаружите, что компилятор сгенерировал соответствующий код IL для проверки
на предмет значений null и обработки их подходящим образом. Это означает, что разные языки в
таких вопросах могут вести себя по-разному — на данный аспект следует в первую очередь обра-
щать внимание при переносе кода между различными языками, основанными на .NET. Например,
в VB поднятые операции трактуются гораздо ближе к тому, как они реализованы в SQL, поэтому
результатом х < у будет Nothing, если х или у имеет значение Nothing.
Для типов, допускающих null, теперь доступна еще одна известная операция с поведением,
которое легко предсказать, если обратиться к существующим знаниям ссылок null и просто
подкорректировать их с учетом понятия значений null.
Я всегда предполагал, что одна проверка должна выполняться быстрее двух проверок, но похоже
здесь ситуация иная — во всяком случае, с версиями .NET, на которых я проводил тестирование
(включая .NET 4.5). В написанном простом эталонном тесте производительности, который суммиру-
ет все целые числа внутри массива типа object[], где только треть значений были упакованными
целыми, применение is с последующим приведением оказалось в 20 раз быстрее, чем исполь-
зование операции as. Подробный анализ этого выходит за рамки настоящей книги. Как обычно,
перед избранием наилучшего направления действий в конкретной ситуации вы должны проверять
производительность написанного кода, но об упомянутой выше проблеме полезно знать.
3. В противном случае выполнение оценки second; после этого результат становится резуль-
татом целого выражения.
Формулировка “грубо говоря” применена из-за того, что официальные правила в спецификации
должны иметь дело с ситуациями, предусматривающими преобразования между типами операндов
first и second. Как обычно, они не важны в большинстве случаев применения операции ??,
Глава 4. Типы, допускающие значения null 155
поэтому здесь они не рассматриваются; если вас интересуют подробности, почитайте раздел 7.13
(“The Null Coalescing Operator” (“Операция объединения с null”)) спецификации.
Важно отметить, что если тип операнда second является базовым типом операнда first
(и, таким образом, не допускающим null), общий результат имеет этот базовый тип. Например,
показанный ниже код совершенно допустим:
int? а = 5;
int b = 10;
int с = а ?? b;
Обратите внимание на присваивание напрямую переменной с, несмотря на то, что ее тип —
int, не допускающий null. Это можно делать только потому, что тип переменной b не допускает
null, следовательно, известно, что в конечном итоге будет получен результат типа, не допускаю-
щего null.
Очевидно, что данный пример сильно упрощен; давайте найдем более практичное применение
для этой операции, вернувшись к свойству Age из листинга 4.4. В качестве напоминания, вот как
оно было реализовано ранее вместе со связанными объявлениями переменных:
DateTime birth;
DateTime? death;
public TimeSpan Age
{
get
{
if (death == null)
{
return DateTime.Now - birth;
}
else
{
return death.Value - birth;
}
}
}
Обратите внимание, что в обеих ветвях оператора if производится вычитание значения пе-
ременной birth из некоторого значения типа DateTime, отличного от null. Нас интересует
актуальный день в жизни человека — время его смерти, если это уже произошло, или теку-
щее время в противном случае. Чтобы добиться постепенного прогресса, попробуем для начала
воспользоваться нормальной условной операцией:
DateTime lastAlive = (death == null ? DateTime.Now : death.Value);
return lastAlive - birth;
Это своего рода прогресс, но условная операция скорее затруднила чтение кода, чем упро-
стила его, несмотря на то, что новый код стал короче. С условной операцией часто так проис-
ходит — частота ее применения зависит от личных предпочтений, хотя прежде чем интенсивно
ею пользоваться, имеет смысл проконсультироваться с остальными членами команды разработчи-
ков. Давайте посмотрим, как с помощью операции объединения с null улучшить положение дел.
Значение переменной death должно применяться, если оно отлично от null, иначе необходимо
использовать DateTime.Now. Реализацию можно изменить следующим образом:
DateTime lastAlive = death ?? DateTime.Now;
return lastAlive - birth;
Глава 4. Типы, допускающие значения null 156
Обратите внимание на то, что типом результата является DateTime, а не DateTime?, по-
скольку в качестве второго операнда применяется свойство DateTime.Now. Реализацию можно
было бы сократить до одного выражения:
Однако при этом теряется ясность — в частности, имя переменной lastAlive в двухстрочной
версии помогает понять, по какой причине используется операция объединения с null. Полагаю,
вы согласитесь с тем, что двухстрочная версия проще и читабельнее, чем первоначальная версия с
оператором if или версия, в которой применяется обычная условная операция из C# 1. Разумеет-
ся, при этом предполагается понимание того, что делает операция объединения с null. Согласно
моему опыту, это один из наименее известных аспектов C# 2, но он достаточно ценен для того,
чтобы проинформировать о нем своих коллег, а не всячески избегать его применения.
Существуют еще два аспекта, которые увеличивают пользу этой операции. Прежде всего, она
применима не только к типам значений, допускающих null — она работает также и ссылочными
типами; просто в первом операнде нельзя использовать тип значения, допускающий null, т.к.
это бессмысленно. Кроме того, эта операция является правоассоциативной, т.е. выражение в
форме first ?? second ?? third оценивается как first ?? (second ?? third) — и так
продолжается для большего числа операндов. Можно иметь любое количество выражений, и они
будут оцениваться по порядку с остановом на первом результате, отличном от null. Если все
выражения оценены как null, результатом также будет null.
Рассмотрим конкретный пример. Предположим, что имеется онлайновая система заказов с
концепциями адресов для платежа, контакта и доставки. В бизнес-правилах заявлено, что любой
пользователь должен иметь адрес для платежа, но не обязательно адрес для контакта. Адрес для
доставки в отдельном заказе также не является обязательным и по умолчанию совпадает с адресом
для платежа. В коде эти необязательные адреса легко представляются как ссылки null. Чтобы
определить, с кем нужно связаться в случае проблемы с доставкой, можно написать следующий
код C# 1:
Применение условной операции в данной ситуации дает еще более запутанный код. Однако
использование операции объединения с null существенно упрощает код:
Если бизнес-правила изменятся, чтобы установить применение по умолчанию адреса для до-
ставки вместо адреса для контакта, то изменение здесь совершенно очевидно. Оно не будет чрез-
мерно трудным по сравнению с версией if/else, но мне придется дважды подумать и мысленно
проверить код. Я также полагаюсь на модульное тестирование, так что шансы ошибиться неве-
лики, но я предпочитаю не думать о подобного рода вещах, если только в этом не возникает
абсолютная необходимость.
Глава 4. Типы, допускающие значения null 157
Все в меру
На тот случай, если вы думаете, что мой код захламлен операциями объединения с null — на
самом деле это не так. Я стремлюсь обдумывать ее использование, когда сталкиваюсь с меха-
низмами установки стандартных значений, в которых задействованы значения null и возможно
условная операция, но это происходит нечасто. Тем не менее, когда случай применения операции
объединения с null является естественным, она может быть мощным инструментом в достижении
лучшей читабельности кода.
Вы уже видели, как использовать типы, допускающие null, для обычных свойств объектов —
случаи, при которых вполне естественно может отсутствовать значение для какого-то аспекта,
по-прежнему лучше всего выражаемого с помощью типа значения. Таковы наиболее очевидные
применения типов, допускающих null, и в действительности они же являются самыми распро-
страненными. Несколько других шаблонов менее очевидны, но вполне могут оказаться мощными.
Два таких шаблона будут рассматриваться в следующем разделе. Материал предлагается больше
ради интереса, чем в плане изучения поведенческих аспектов самих типов, допускающих null,
поскольку вы уже располагаете всеми инструментами, необходимыми для работы с такими типами
в своем коде. Однако если вас интересуют нестандартные идеи и что-то совершенно новое, смело
читайте следующий раздел.
выполнении, но он не особенно хорошо работает в ситуации, когда в случае успеха null явля-
ется допустимым возвращаемым значением. Примером этих двух утверждений может быть тип
Hashtable, хотя и слегка противоречивым образом. Теоретически null — допустимое значение
в Hashtable, но согласно моему опыту, в подавляющем большинстве ситуаций в Hashtable
значения null не используются, поэтому вполне приемлемо предполагать в коде, что значение
null соответствует отсутствующему ключу.
Распространенный сценарий заключается в том, что каждое значение в Hashtable опреде-
ляется в виде списка: при первом добавлении элемента для отдельного ключа создается новый
список и к нему добавляется элемент. После этого добавление еще одного элемента для того же
самого ключа приводит к добавлению элемента в существующий список. Ниже показан код на
C# 1:
Скорее всего, имена переменных у вас будут больше соответствовать конкретной ситуации,
но я уверен, что вы уловили идею и, возможно, воспользуетесь этим шаблоном в своем коде4 .
Благодаря типам, допускающим null, указанный шаблон может охватить также типы значений.
В случае типов значений он даже безопаснее, поскольку если нормальным возвращаемым типом
является тип значения, то значение null могло бы возвращаться только в результате отказа.
Типы, допускающие null, добавляют такую дополнительную порцию булевского типа в общем
виде при поддержке языка, так почему бы ни прибегнуть к ним?
Для демонстрации этого шаблона на практике и в контексте, отличающемся от поиска в сло-
варе, предлагается рассмотреть классический пример применения шаблона ТrуХХХ() — синтак-
сический разбор целого числа. Реализация метода TryParse() в листинге 4.5 отражает версию
этого шаблона, использующую выходной параметр, но в главной части внизу присутствует также
и версия, в которой применяется тип, допускающий null.
...
int? parsed = TryParse("Not valid");
if (parsed != null)
{
Console.WriteLine ("Parsed to {0}", parsed.Value);
}
else
{
Console.WriteLine ("Couldn't parse"); // He удается провести разбор
}
Может показаться, что показанные две версии мало чем отличаются — в конце концов, даже
количество строк у них одинаковое. Но я считаю, что есть разница в акцентах. Версия с типом,
допускающим null, инкапсулирует естественное возвращаемое значение и признак успешности
или отказа внутри одной переменной. По моему мнению, он также отделяет действие от про-
верки, что смещает акцент в правильную сторону. Обычно если какой-то метод вызывается в
части условия оператора if, то его главной целью является возврат булевского значения. Но в
определенном смысле возвращаемое значение здесь обладает меньшей важностью, чем выходной
параметр. В итоге при чтении кода выходной параметр внутри вызова метода довольно легко упу-
стить из виду, а потом удивляться, откуда волшебным образом был получен результат. Версия с
типом, допускающим null, позволяет выразить намерение более ясно — результат выполнения
метода содержит всю интересующую вас информацию. Я пользовался таким приемом во многих
местах (часто с большим числом параметров метода, когда обнаружить выходные параметры было
еще труднее), и уверен, что он улучшает общий настрой кода. Конечно, прием работает только с
типами значений.
Другое преимущество этого шаблона связано с тем, что его можно применять в сочетании
с операцией объединения с null — попробуйте реализовать распознавание последовательности
входных значений, останавливаясь на первом допустимом значении. Обычный шаблон ТrуХХХ()
позволяет делать это с помощью сокращенно вычисляемых операций, но понять оператор, в кото-
ром одна и та же переменная используется для двух выходных параметров, уже не так легко.
Еще одной альтернативой использованию для представления результата типа, допускающего null,
является применение возвращаемого типа с двумя очень четко разделенными членами, один из ко-
торых отвечает за указание на успех или отказ, в другой — за предоставление значения в случае
успеха. Удобным для этого типом является Nullable<T>, т.к. в нем определено булевское свой-
ство и свойство типа T, но смысл возвращаемого значения можно было бы сделать более ясным.
В состав .NET 4 входит семейство типов Tuple: так может быть тип Tuple<int, bool> здесь
окажется яснее, нежели int?. Даже более ясным может быть специальный тип, предназначенный
для представления результата операции синтаксического разбора: ParseResult<T>, например.
В этом случае значение можно было бы передать другому коду, не опасаясь, что его смысл будет
завуалирован, и добавить дополнительную информацию, такую как причина возникновения отказа
при разборе.
В коде принято допущение, что сравнение ссылок null запрашиваться не будет, а все свой-
ства будут возвращать ссылки, не равные null. Для обработки таких случаев можно было бы
предусмотреть упреждающие сравнения с null и свойство Comparer<T>.Default, но это при-
вело бы к дополнительному росту объема кода и решению новых проблем. Код можно сократить
(избежав возврата из середины метода), слегка переупорядочив его, однако по-прежнему должен
присутствовать фундаментальный шаблон “сравнение, проверка, сравнение, проверка”, и заверше-
ние работы после получения ненулевого ответа не будет настолько очевидным.
Последнее предложение напоминает кое-что другое: операцию объединения с null. Как было
показано в разделе 4.3, при наличии множества выражений, разделенных посредством ??, эта
операция будет многократно применяться, пока не столкнется с выражением, не равным null.
Теперь осталось лишь выработать способ возврата из метода сравнения null вместо нуля. Это
легко сделать в отдельном методе, в котором можно также инкапсулировать использование стан-
дартного компаратора. При желании можно даже иметь перегруженную версию для применения
специфичного компаратора. Можно также учесть случай, когда любая из передаваемых ссылок на
Product является null.
Для начала рассмотрим класс, реализующий вспомогательные методы, как показано в листин-
ге 4.6.
Глава 4. Типы, допускающие значения null 161
Методы Compare() в листинге 4.6 трогательно просты — когда компаратор не указан, при-
меняется стандартный компаратор для типа, и нулевое возвращаемое значение сравнения просто
транслируется в значение null.
Вас может удивить использование конструкции new int?(), а не null, для возврата значения
null во втором методе Compare(). Условная операция требует, чтобы ее второй и третий опе-
ранды либо имели один и тот же тип, либо существовало неявное преобразование из типа одного
операнда в тип другого, а в случае null это не так, поскольку компилятору не известно, к ка-
кому типу должно было относиться значение. При исследовании подвыражений правила языка не
учитывают общую цель оператора (возвращающего из метода значение типа int?). Другие вари-
анты включают или явное приведение операнда к типу int?, или применение для значения null
конструкции default(int?). В принципе, важно убедиться, что один из операндов в точности
является значением типа int?.
4.5 Резюме
Столкнувшись с проблемой, разработчики склонны выбирать самое простое краткосрочное ре-
шение, даже если оно выглядит не особенно элегантно. Часто это решение и является правиль-
ным — в конце концов, вам же не нужны обвинения в выполнении излишней работы. Всегда
приятно, когда хорошее решение также оказывается простейшим.
Типы, допускающие null, решают конкретную проблему, которая единственная имела неук-
люжие решения до появления C# 2. Предоставленные возможности позволяют получить лучше
поддерживаемую версию решения, которая была осуществима в C# 1, но требовала больших вре-
менных затрат. Сочетание обобщений (позволяющих избежать дублирования кода), поддержки со
стороны среды CLR (для обеспечения подходящего поведения упаковки и распаковки) и языковой
поддержки (для предоставления согласованного синтаксиса наряду с удобными преобразованиями
и операциями) делает решение намного более мощным и убедительным, чем было ранее.
Глава 4. Типы, допускающие значения null 163
Оперативно о делегатах
В этой главе...
• Многословный синтаксис C# 1
• Ковариантность и контравариантность
• Анонимные методы
• Захваченные переменные
куда приведут эти пути. Пока что их инстинкты оказались удивительно благотворными в области
делегатов.
Делегаты играют более заметную роль в .NET 2.0, чем в предшествующих версиях, хотя и
не такую значительную, как в .NET 3.5. В главе 3 было показано, как их можно применять для
преобразования из одного типа списка в другой, а в главе 1 осуществлялась сортировка списка
товаров с использованием делегата Comparison вместо интерфейса IComparer. Хотя между
инфраструктурой и языком C# сохраняется почтительное расстояние, где только возможно, я
уверен, что язык и платформа в этом случае влияли друг на друга: добавление поддержки больше
ориентированных на делегаты обращений к API-интерфейсам улучшает синтаксис, доступный в
C# 2, и наоборот.
В этой главе мы рассмотрим два небольших изменения в C# 2, которые позволяют упростить
создание экземпляров делегатов из обычных методов, и затем взглянем на крупнейшее измене-
ние — анонимные методы, которые позволяют указывать действие экземпляра делегата прямо
в точке его создания. Самый большой по объему раздел главы выделен для освещения наи-
более сложной части в рамках темы анонимных методов — захваченным переменным, которые
предоставляют экземплярам делегатов среду с увеличенными возможностями. Ввиду важности
и сложности эта тема будет раскрыта максимально подробно. После того как вы разберетесь с
анонимными методами, понять лямбда-выражения не составит особого труда.
Однако, прежде всего, давайте вспомним недостатки делегатов в версии C# 1.
они часто приводят к усложнению восприятия кода и засорению кода класса множеством методов,
которые используются только для делегатов.
Неудивительно, что все эти аспекты в C# 2 были существенно улучшены. Синтаксис по-
прежнему может оказаться более многословным, чем того хотелось (до появления лямбда-выраже-
ний в C# 3), но разница значительна. Чтобы проиллюстрировать проблему, мы начнем с опре-
деленного кода на C# 1, который будем улучшать в следующих двух разделах. В листинге 5.1
строится очень простая форма с кнопкой и производится подписка на три события этой кнопки.
Как отдельное выражение, оно не выглядит слишком плохо. Даже при простой подписке на
событие это вполне сносно. Однако конструкция становится неуклюжей, когда она является частью
более длинного выражения. Распространенным примером может служить запуск нового потока:
Thread t = new Thread(new ThreadStart(MyMethod));
Все, что здесь требуется сделать — запустить новый поток, который будет выполнять метод
MyMethod(). Как обычно, желательно выразить все максимально просто, и версия С# 2 позво-
ляет реализовать это посредством неявного преобразования группы методов в совместимый тип
делегата. Группа методов — это просто имя метода с необязательной целью, т.е. точно такой же
вид выражения, который использовался в C# 1 для создания экземпляров делегатов. (На самом
деле тогда само выражение называлось группой методов — из-за того, что такое преобразование
просто не было доступно.) Если метод является обобщенным, то группа методов может также
указывать аргументы типов, хотя, согласно моему опыту, это встречается редко. Новое неявное
преобразование позволяет реализовать подписку на событие следующим образом:
button.KeyPress += LogKeyEvent;
Разница в читабельности исходной и упрощенной версий не так велика для одной строки, но
в контексте значительного объема кода они могут существенно сократить беспорядок. Чтобы это
не выглядело какой-то магией, рассмотрим, что делает указанное преобразование.
Для начала взглянем на выражения LogKeyEvent() и MyMethod() из приведенных при-
меров. Причина их классификации как групп методов в том, что из-за перегрузки может быть
доступно более одного метода. Доступное неявное преобразование будет преобразовывать группу
методов в любой тип делегата с совместимой сигнатурой. Пусть имеются две сигнатуры методов,
показанные ниже:
void MyMethod()
void MyMethod(object sender, EventArgs e)
Тогда MyMethod() можно применить как группу методов в присваивании либо ThreadStart,
либо EventHandler:
ThreadStart х = MyMethod;
EventHandler у = MyMethod;
Тем не менее, его нельзя использовать в качестве параметра метода, который сам был перегру-
жен для приема экземпляра ThreadStart или EventHandler — компилятор сообщит о том, что
такой вызов неоднозначен. Подобным же образом, к сожалению, преобразование группы методов
невозможно применять для преобразования в простой тип System.Delegate, поскольку компи-
лятору не известен конкретный тип делегата, экземпляр которого должен быть создан. Возникает
сложность, но, во всяком случае, можно еще немного сократить код по сравнению с кодом на C#
1, сделав преобразование явным. Ниже приведен пример:
Для локальных переменных это обычно не проблема, но становится таковой при использова-
нии API-интерфейса, который имеет параметр типа Delegate, такой как Control.Invoke().
Здесь существует несколько решений: применение вспомогательного метода, приведение или ис-
пользование промежуточной переменной. Далее представлен пример применения типа делегата
MethodInvoker, который не принимает параметров и не имеет возвращаемого типа:
Разные ситуации способствуют построению разных решений; ни одно из них не является особо
привлекательным, однако их нельзя считать и ужасными1 .
Как и в случае обобщений, точные правила для определения допустимости преобразования
довольно сложны, поэтому хорошо работает подход с апробированием; если компилятор уведомит
о том, что ему не хватает информации, нужно лишь сообщить ему, какое преобразование исполь-
зовать, и все должно пройти нормально. За подробными сведениями обращайтесь в раздел 6.6
(“Method group conversions” (“Преобразования групп методов”)) спецификации языка. Возмож-
ных преобразований может быть больше, чем кажется, в чем вы сможете убедиться в следующем
разделе.
1
Расширяющие методы (обсуждаемые в главе 10) делают подход со вспомогательным методом несколько более
привлекательным при использовании версии C# 3.
Глава 5. Оперативно о делегатах 169
Два метода обработчиков, которые имели дело с событиями клавиатуры и мыши, были устране-
ны и теперь для обработки всех событий используется один метод Ê. Разумеется, это не особенно
полезно, если нужно проводить отличия между разными типами событий, но иногда все, что
требуется знать — это сам факт возникновения события и, возможно, его источник. При под-
писке на событие Click Ë применяется только неявное преобразование, которое обсуждалось в
2
Часть public delegate была удалена ради краткости.
Глава 5. Оперативно о делегатах 170
предыдущем разделе, т.к. оно имеет простой параметр EventArgs, но другие подписки на собы-
тия Ì предусматривают использование преобразования и контравариантности из-за разных типов
параметров.
Ранее упоминалось, что соглашение относительно обработчиков событий в .NET 1.0/1.1 не име-
ло большого смысла, когда оно было впервые введено. Данный пример наглядно демонстрирует,
почему руководящие принципы более полезны в версии C# 2. Соглашение предписывает, что об-
работчики событий должны иметь сигнатуру с двумя параметрами, первый из которых относится
к типу object и представляет источник события, а второй хранит дополнительную информацию
о событии в экземпляре типа, производного от EventArgs. До того как стала доступной контра-
вариантность, это было бесполезно — определение параметра для дополнительной информации с
типом, унаследованным от EventArgs, не давало никаких преимуществ, а иногда не было осо-
бого смысла в работе с источником события. Часто было более разумно передавать необходимую
информацию непосредственно в виде обычных параметров подходящих типов, как это делается в
случае любого другого метода. Теперь можно применять метод с сигнатурой EventHandler()
как действия для любого типа делегата, который следует соглашению.
До сих пор мы имели дело с входными значениями метода или делегата, а что можно сказать
о выходном значении?
Теперь этот тип можно применять с методом, который объявлен как возвращающий специаль-
ный тип потока (листинг 5.3). Объявленный метод всегда возвращает экземпляр MemoryStream
с последовательностью данных (байты 0, 1, 2 и так далее до 15). Этот метод используется в каче-
стве действия для экземпляра делегата StreamFactory.
3
Ковариантность возвращаемых типов и ковариантность типов параметров могут применяться одновременно, но
вряд ли вы столкнетесь с ситуациями, в которых это бы пригодилось.
Глава 5. Оперативно о делегатах 171
using (Stream stream = factory()) ❹Обращение к делегату для получения экземпляра потока
{
int data;
while ((data = stream.ReadByte()) != -1)
{
Console.WriteLine(data);
}
}
Генерация и отображение данных в листинге 5.3 нужны просто для того, чтобы код делал
что-нибудь полезное. Важными являются аннотированные строки. В объявленном типе делегата
применяется возвращаемый тип Stream Ê, но метод GenerateSampleData() имеет возвра-
щаемый тип MemoryStream Ë. Строка кода, в которой создается экземпляр делегата Ì, вы-
полняет упомянутое ранее преобразование и использует ковариантность возвращаемых типов,
чтобы позволить методу GenerateSampleData() выступать в качестве действия для делега-
та StreamFactory. До момента вызова экземпляра делегата Í компилятору ничего не известно
о том, что будет возвращен экземпляр типа MemoryStream — если изменить тип переменной
stream на MemoryStream, возникнет ошибка при компиляции.
Ковариантность и контравариантность могут также применяться для конструирования одного
экземпляра делегата на основе другого. Например, рассмотрим следующие две строки кода (в
которых предполагается наличие соответствующего метода HandleEvent()):
Вспомните, что инструмент Snippy4 будет генерировать весь этот код внутри класса по имени
Snippet, от которого наследуется вложенный тип. В случае C# 1 код из листинга 5.4 выведет на
консоль строку Snippet.CandidateAction, поскольку метод, принимающий параметр object,
не был совместимым с SampleDelegate(). В случае C# 2 метод является совместимым, так что
он будет выбран по причине объявления в более производном типе, поэтому на консоль выводится
строка Derived.CandidateAction.
К счастью, компилятору C# 2 известно, что это нарушающее изменение, и он выдает соответ-
ствующее предупреждение. Данный раздел включен потому, что вы должны быть осведомлены о
самой возможности такой проблемы, тем не менее, я уверен, что вы редко с ней столкнетесь в
реальности.
Потенциальное нарушение достаточно отпугивает. Однако мы пока еще не обратились к самому
важному новому средству, относящемуся к делегатам — анонимным методам. Они несколько
сложнее, чем рассмотренные до сих пор темы, но они также отличаются большой мощностью и
являются крупным шагом в сторону версии C# 3.
удается. А вот анонимные методы, которые также являются нововведением версии C# 2, могут
почти всегда помочь в решении указанных ранее проблем.
Неформально анонимные методы позволяют указывать действие для экземпляра делегата встро-
енным образом как часть выражения, создающего этот экземпляр делегата. Они также предостав-
ляют гораздо более мощное поведение в форме замыканий, но они будут рассматриваться в
разделе 5.5. Пока что давайте придерживаться относительно простой функциональности.
Сначала мы рассмотрим примеры анонимных методов, которые принимают параметры, но не
возвращают значений, а затем исследуем синтаксис, используемый для возвращения значений, и
также сокращение, доступное на случай, когда переданные значения параметров не нужны.
Другими словами, делегат Action<T> делает что-то со значением типа Т; например, Action
<string> мог бы изменять порядок следования символов в строке на противоположный и вы-
водить результат на консоль, Action<int> — выводить значение квадратного корня для пере-
данного числа, a Action<IList<double>> — находить среднее для всех указанных чисел и
выводить его на консоль. В листинге 5.5 все эти примеры реализованы с использованием аноним-
ных методов.
printRoot(2);
printMean(new double[] { 1.5, 2.5, 3, 4.5 });
Глава 5. Оперативно о делегатах 174
Пара ограничений...
Одна небольшая особенность связана с тем, что если анонимный метод пишется в типе значения,
то внутри него нельзя ссылаться на this. В ссылочном типе такое ограничение отсутствует. Кроме
того, в предлагаемых Microsoft реализациях компиляторов C# 2 и C# 3 доступ к базовому члену
внутри анонимного метода через ключевое слово base приводило к выдаче предупреждения о
том, что результирующий код является не поддающимся проверке. В компиляторе C# 4 данная
проблема была устранена.
В терминах реализации для каждого анонимного метода в исходном коде по-прежнему созда-
ется метод в коде IL. Компилятор сгенерирует метод внутри существующего класса, а затем будет
использовать его в качестве действия при создании экземпляра делегата, как если бы это был
обычный метод5 . Среда CLR не знает и не заботится о том, что применялся анонимный метод.
Просмотреть эти дополнительные методы в скомпилированном коде можно с помощью инстру-
мента ildasm или Reflector. (Инструменту Reflector известно, как интерпретировать код IL для
отображения анонимных методов в методе, который их использует, и дополнительные методы по-
прежнему видимы.) Такие методы имеют непроизносимые имена — имена, которые допустимы
в IL, но недопустимы в С#. Это препятствует попыткам ссылаться на данные методы напрямую
в коде C# и устраняет возможность возникновения конфликтов имен. Многие средства C# 2 и
последующих версий реализованы похожим образом; проще всего их обнаружить по наличию уг-
ловых скобок. Например, анонимный метод внутри метода Main() может привести к созданию
метода по имени <Main>b__0() . Тем не менее, это всецело зависит от реализации. Например,
в будущей версии компилятора Microsoft могут быть изменены собственные соглашения. Это не
должно что-либо нарушить, поскольку ничего не должно полагаться на такие имена.
На этом этапе полезно отметить, что код в листинге 5.5 совершенно не похож на то, как ано-
нимные методы обычно выглядят в реальном коде. Вы часто будете сталкиваться с их применением
в качестве аргументов другого метода (вместо присваивания переменной типа делегата) и они бу-
дут разнесены на несколько строк — в конце концов, компактность является одной из причин их
использования. Чтобы продемонстрировать это, мы воспользуемся методом List<T>.ForEach(),
который принимает Action<T> как параметр и выполняет это действие над каждым элементом.
В листинге 5.6 показан экстремальный пример, в котором применяется то же самое действия
извлечения квадратного корня, что и в листинге 5.5, но в компактной форме.
5
В разделе 5.5.4 вы увидите, что хотя всегда существует новый метод, он не всегда создается там, где этого можно
было ожидать.
Глава 5. Оперативно о делегатах 175
Код выглядит довольно-таки устрашающе — особенно, с учетом того, что последние шесть
символов производят впечатление расположенных наугад. Конечно, существует и золотая середи-
на. В отношении анонимных методов я предпочитаю нарушать свое обычное правило “фигурные
скобки в собственной строке” (которое применяю к простым свойствам), но по-прежнему допускаю
порядочное количество пробельных символов. Последнюю строку кода из листинга 5.6 я мог бы
также написать в следующих двух формах:
x.ForEach(delegate(int n)
{ Console.WriteLine(Math.Sqrt(n)); }
);
x.ForEach(delegate(int n) {
Console.WriteLine(Math.Sqrt(n));
});
Даже простое добавление пробелов в код из листинга 5.6 способствует его лучшему пони-
манию. В каждом из этих форматов круглые и фигурные скобки теперь меньше запутывают, а
часть, отвечающая за полезную работу, соответствующим образом выделена. Конечно, разбивка
кода всецело зависит от ваших предпочтений, но я рекомендую хорошо подумать о соблюдении
определенного баланса и обсудить с членами команды вопросы по достижению некоторой согласо-
ванности. Тем не менее, согласованность не всегда приводит к получению наиболее читабельного
кода — иногда представление всей функциональности в одной строке является самым простым
форматом.
До сих пор взаимодействие с вызывающим кодом осуществлялось с помощью параметров. А
как насчет возвращаемых значений?
В листинге 5.7 приведен код анонимного метода, который создает экземпляр делегата Predicate
<T> для возвращения признака четности или нечетности передаваемого ему аргумента. Предикаты
обычно применяются при фильтрации и сопоставлении — к примеру, код из листинга 5.7 можно
было бы использовать для фильтрации списка с целью получения только четных элементов.
Глава 5. Оперативно о делегатах 176
Console.WriteLine(isEven(1));
Console.WriteLine(isEven(4));
Возвращение значения из анонимного метода — это всего лишь возвращение из анонимного мето-
да, а не возвращение из метода, создающего экземпляр делегата. Будьте внимательны, т.к. увидев
ключевое слово return в некотором коде, легко посчитать его точкой выхода из текущего метода.
Как упоминалось ранее, в .NET 2.0 лишь относительно немногие делегаты возвращают зна-
чения, хотя, как будет показано в части 3 этой книги, в .NET 3.5 идея возврата значений при-
меняется гораздо чаще, особенно в случае LINQ. Тем не менее, в .NET 2.0 имеется еще один
довольно популярный тип делегата: Comparison<T>, который может использоваться при сорти-
ровке коллекций. Это эквивалент интерфейса IComparer<T> в форме делегата. Часто возникает
ситуация, когда необходим только определенный порядок сортировки, в связи с чем нужна воз-
можность указывать требуемый порядок встроенным образом, а не открывать его в виде метода
для остальной части класса. Сказанное демонстрируется в листинге 5.8, код в котором выводит
на консоль список файлов в каталоге С:\, упорядочивая сначала по имени, а затем (отдельно) по
размеру.
Листинг 5.8. Использование анонимных методов для простой сортировки имен файлов
Array.Sort(files, sortOrder);
Console.WriteLine(title);
foreach (Filelnfo file in files)
{
Console.WriteLine(" {0} ({1} bytes)", file.Name, file.Length);
}
}
...
SortAndShowFiles("Sorted by name:", delegate(FileInfo f1, FileInfo f2)
Глава 5. Оперативно о делегатах 177
{ return f1.Name.CompareTo(f2.Name); }
);
SortAndShowFiles("Sorted by length:", delegate(FileInfo f1, FileInfo f2)
{ return f1.Length.CompareTo(f2.Length); }
);
Здесь понапрасну тратится много места без веских на то оснований — значения параметров не
нужны, поэтому компилятор разрешает не указывать их вообще.
Глава 5. Оперативно о делегатах 178
Я нахожу это сокращение наиболее удобным, когда дело доходит до реализации собственных
событий. Например, мне категорически не нравится необходимость в выполнении проверки на
предмет null перед генерацией события.
Один из способов обойти это предполагает обеспечение того, что событие запускается с помо-
щью обработчика, который затем никогда не удаляется. Учитывая, что этот обработчик ничего не
делает, теряется лишь небольшая часть производительности. До появления C# 2 нужно было яв-
но создавать метод с правильной сигнатурой, что не приносило никакой пользы, но теперь можно
писать такой код:
Начиная с этого момента, метод Click() можно просто вызывать безо всяких проверок на
предмет null.
Вы должны знать об одной ловушке, связанной с этой возможностью подстановки парамет-
ров — если анонимный метод может быть преобразован в несколько типов делегатов (например,
для вызова различных перегруженных версий метода), то компилятору понадобится дополнитель-
ная помощь. Чтобы прояснить, о чем идет речь, возвратимся к тому же самому трудному примеру,
который рассматривался для преобразований групп методов: запуск нового потока. В .NET 2.0
доступны четыре конструктора потоков:
Первая и вторая строки содержат списки параметров — компилятору известно, что анонимный
метод из первой строки не может быть преобразован в ParameterizedThreadStart(), а ано-
нимный метод из второй строки — в ThreadStart(). Эти строки компилируются, поскольку в
каждом случае существует только одна подходящая перегруженная версия конструктора. Однако
третья строка неоднозначна — анонимный метод может быть преобразован в любой из двух типов
делегатов, поэтому применимы обе перегруженных версии конструктора с одним параметром. В
такой ситуации компилятор выдает сообщение об ошибке. Решить проблему можно либо явным
указанием списка параметров, либо приведением анонимного метода к правильному типу делегата.
Надеюсь, что анонимные методы, которые вы видели до сих пор, поспособствовали опреде-
ленным размышлениям о вашем коде и возможным применениям этих приемов для достижения
хороших результатов. И действительно, даже если бы анонимные методы могли делать только
то, что было показано ранее, они уже были бы очень удобны. Но анонимные методы позволяют
не только избежать определения дополнительных методов в коде. Анонимные методы представля-
ют собой реализацию версией C# 2 средства, известного в других местах как замыкания через
захваченные переменные. В следующем разделе объясняются оба эти термина и показано, что
анонимные методы могут оказаться исключительно полезными — но также и запутанными в слу-
чае неосторожного применения.
Глава 5. Оперативно о делегатах 179
• Внешняя переменная — это локальная переменная или параметр (кроме параметров ref и
out), область действия которой включает анонимный метод. Ссылка this также считается
внешней переменной любого анонимного метода внутри члена экземпляра класса.
Возможно, это крайне сухое изложение трудно понять, но основной смысл в том, что в ано-
нимном методе могут применяться локальные переменные, определенные внутри метода, в кото-
ром объявлен этот анонимный метод. Сказанное может не выглядеть особенно важным, но во
многих ситуациях это чрезвычайно удобно — можно использовать имеющуюся в распоряжении
контекстную информацию вместо того, чтобы настраивать дополнительные типы лишь для хране-
ния данных, которые уже известны. Вскоре мы рассмотрим полезные конкретные примеры, но до
этого имеет смысл взглянуть на код, проясняющий приведенные выше определения.
В листинге 5.10 представлен пример с несколькими локальными переменными и единственным
методом, поэтому код не может быть запущен сам по себе. В данный момент объяснения, как
этот код работает, приводиться не будут, а речь пойдет о классификации разных переменных. Тип
делегата MethodInvoker применяется в целях простоты.
6
Это общая терминология, принятая в вычислительной технике, а не терминология языка С#.
Глава 5. Оперативно о делегатах 180
void EnclosingMethod()
{
int outerVariable = 5; ❶ Внешняя переменная (незахваченная)
};
x();
}
Теперь вы знаете терминологию, но пока еще не сильно приблизились к пониманию того, что
делают захваченные переменные. Я подозреваю, что вы смогли бы предугадать вывод, получаемый
в результате выполнения метода из листинга 5.10, тем не менее, имеется ряд других случаев,
которые, возможно, вызовут удивление. Мы начнем с простого примера и построим на его основе
более сложные примеры.
имеет далеко идущие последствия, но сначала необходимо понять смысл данного утверждения в
сравнительно простой ситуации.
В листинге 5.11 имеется захваченная переменная и анонимный метод, который выводит на
консоль значение этой переменной и затем изменяет его. Вы заметите, что изменение значения
переменной за пределами анонимного метода видно внутри анонимного метода и наоборот.
Даже если вы понимаете абсолютно весь материал, приведенный до сих пор, может возникнуть
вопрос: для чего все это нужно делать? Наступило время рассмотреть действительно полезный
пример.
Здесь внутри экземпляра делегата захватывается параметр limit — если бы имелись только
анонимные методы, но не захваченные переменные, пришлось бы выполнять проверку с жестко
закодированным пределом, а не с тем, который передается методу в виде параметра. Надеюсь, вы
согласитесь с тем, что такой подход аккуратнее: он точно выражает то, что необходимо делать,
с гораздо меньшей неразберихой, возникающей при точном выражении того, каким образом это
должно случиться, что можно было наблюдать в версии кода на C# 1. (Надо сказать, что в версии
C# 3 все даже еще более аккуратно...7 ) Ситуация, когда требуется записывать в захваченную
переменную, возникает относительно редко, но такое использование вполне допустимо.
Вы все еще здесь? Тогда продолжим. До сих пор экземпляр делегата применялся только внутри
метода, в котором он был создан. При этом не возникали многие вопросы относительно времени
жизни захваченных переменных — но что произойдет, если экземпляр делегата выйдет за узкие
рамки своего метода? Как он будет существовать после того, как создавший его метод завершится?
Не переживайте, если это правило выглядит не особенно понятным — оно станет яснее при
рассмотрении примера. В листинге 5.12 показан метод, который возвращает экземпляр делега-
та. Этот экземпляр делегата создан с использованием анонимного метода, который захватывает
внешнюю переменную. Так что же произойдет, когда этот делегат будет вызван после возврата
управления из метода?
Вывод кода из листинга 5.12 содержит числа 5, 6 и 7 в отдельных строках. Первая строка
вывода поступает из вызова экземпляра делегата внутри метода CreateDelegateInstance(),
поэтому имеет смысл утверждать, что значение counter доступно в данной точке. Но что можно
сказать о моменте, когда уже произошел возврат из метода? Обычно предполагается, что перемен-
ная counter находится в стеке, а когда стековый фрейм для метода CreateDelegateInstance()
уничтожен, можно считать, что переменная counter по существу должна была бы исчезнуть..., но
кажется, что последующие вызовы возвращенного экземпляра делегата продолжают использовать
ее.
Секрет кроется в некорректности предположения о том, что переменная counter находится в
стеке. Это не так. Для хранения переменной компилятор в действительности создает дополнитель-
ный класс. Метод CreateDelegateInstance() имеет ссылку на экземпляр этого класса, так
что он может использовать counter, а делегат имеет ссылку на тот же самый экземпляр, кото-
рый в обычных условиях находится в куче. Этот экземпляр не может быть обработан сборщиком
мусора до тех пор, пока к сборке мусора не будет готов сам делегат.
Определенные аспекты анонимных методов сильно зависят от компилятора (разные компиля-
торы могут обеспечивать одну и ту же семантику по-разному), но трудно увидеть, каким образом
указанное поведение могло быть получено без применения дополнительного класса, предназна-
ченного для хранения захваченной переменной. Обратите внимание, что если захватить только
this, никакие дополнительные типы не требуются — компилятор просто создает метод экзем-
пляра, выступающий в качестве действия делегата. Как упоминалось ранее, не следует слишком
сильно беспокоиться о деталях, связанных со стеком и кучей, но полезно знать, какие функции
Глава 5. Оперативно о делегатах 184
8
На мой взгляд, повторное объявление переменной, если только не требуется поддержка ее значения между
итерациями, также обеспечивает более ясный код.
Глава 5. Оперативно о делегатах 185
counter++;
});
}
foreach (MethodInvoker t in list)
{
t(); ❸ Выполнение всех пяти экземпляров делегата
}
list[0](); ❹ Трехкратное выполнение первого экземпляра делегата
list[0]();
list[0]();
list[1](); ❺ Однократное выполнение второго экземпляра делегата
В C# 5 это изменилось...
Хотя описанное выше поведение в цикле for вполне обоснованно — в конце концов, переменная
выглядит как объявленная только раз — в случае цикла foreach оно неожиданно. На самом
деле, почти всегда неправильно захватывать итерационную переменную foreach в анонимном
методе, который должен существовать за рамками непосредственной итерации. (Если экземпляр
делегата используется только внутри данной итерации, то все в порядке.) Это создавало проблемы
у настолько большого числа разработчиков, что в команде проектировщиков языка C# в версии
C# 5 решили изменить семантику цикла foreach, чтобы он действовал более естественным обра-
зом — как если бы для каждой итерации была предусмотрена собственная отдельная переменная.
Дополнительные сведения ищите в разделе 16.1.
{
Console.WriteLine ("({0},{1})", outside, inside);
outside++;
inside++;
};
}
first();
first();
first();
second();
second();
Сколько времени вам понадобится на обдумывание вывода кода из листинга 5.14 (даже с уче-
том аннотаций)? Честно говоря, у меня это заняло некоторое время — больше, чем я предпочитаю
тратить на понимание кода. Тем не менее, в качестве упражнения давайте посмотрим, что тут
происходит.
Прежде всего, взгляните на переменную outside Ê. Управление заходит в область действия,
в которой она объявлена, только однажды — по существу есть лишь один ее экземпляр. С пе-
ременной inside Ë дела обстоят по-другому — на каждой итерации цикла создается новый ее
экземпляр. Это означает, что при создании экземпляра делегата Ì переменная outside разделя-
ется между двумя экземплярами делегата, но каждый из них обладает собственной переменной
inside.
После завершения цикла трижды вызывается первый созданный экземпляр делегата. Посколь-
Глава 5. Оперативно о делегатах 187
ку каждый раз он инкрементирует значения обеих захваченных переменных, и обе они начинаются
с 0, вы видите вывод (0,0), затем (1,1) и, наконец, (2,2). Разница между этими двумя переменными
в терминах области действия становится заметной при выполнении второго экземпляра делегата.
Он имеет другую переменную inside, которая по-прежнему содержит значение 0, однако пере-
менная outside является той, что уже была инкрементирована три раза. Вывод, получаемый в
результате двукратного вызова второго экземпляра делегата, выглядит следующим образом: (3,0)
и (4,1).
Просто ради интереса подумаем о том, как это реализовано — по крайней мере, в компиля-
торе C# 2 от Microsoft. На самом деле генерируется один дополнительный класс для хранения
переменной outside и еще один — для удержания переменной inside и ссылки на первый
дополнительный класс. В сущности, каждая область действия, которая содержит захваченную
переменную, получает собственный тип, со ссылкой на следующую область действия, содержа-
щую захваченную переменную. В этом примере существуют два экземпляра типа, хранящего
inside, и они оба ссылаются на один и тот же экземпляр типа, который хранит outside. Дру-
гие реализации могут варьироваться, но выше был описан наиболее очевидный способ. На рис.
5.1 показаны значения после выполнения кода из листинга 5.14. (Имена, указанные на рисунке,
не точно совпадают с теми, которые сгенерирует компилятор, но они довольно близки к ним.
Обратите внимание, что в реальности экземпляры делегата будут также иметь и другие члены,
однако здесь нас интересует только target.)
first second
target target
ref ref
inside inside
3 2
Даже после полного понимания этого кода он по-прежнему продолжает быть хорошим шаб-
лоном для проведения экспериментов с другими элементами захваченных переменных. Как упо-
миналось ранее, определенные элементы захватывания переменных являются специфичными для
реализации, и для выяснения того, что они гарантируют, часто полезно обращаться к специфика-
ции. Но не менее важно экспериментировать с кодом и наблюдать за тем, что происходит.
Возможно, и есть ситуации, при которых код вроде приведенного в листинге 5.14 может ока-
заться самым простым и ясным способом выражения желаемого поведения, но мне трудно в это
поверить, и даже если так, то код определенно должен быть снабжен комментариями, объясняю-
щими, что будет происходить. Итак, когда уместно применять захваченные переменные и на что
при этом обращать внимание?
Глава 5. Оперативно о делегатах 188
• Соблюдайте осторожность! Простой код почти всегда лучше слишком заумного кода.
5.6 Резюме
В версии C# 2 радикально изменились способы создания делегатов, что обеспечило для инфра-
структуры более функциональный стиль программирования. В .NET 2.0 доступно больше методов,
принимающих делегаты в качестве параметров, чем в .NET 1.0/1.1, и такая тенденция продол-
жается в .NET 3.5. Лучшим примером может служить тип List<T>, который также является
качественным испытательным стендом для проверки уровня мастерства при работе с анонимны-
ми методами и захваченными переменными. Программирование в таком стиле требует несколько
иного образа мыслей. Вы должны быть в состоянии сделать шаг назад и подумать, в чем заклю-
чается конечная цель, и как ее лучше выразить — в традиционной манере C# или с помощью
функционального подхода.
Все изменения в обработке делегатов полезны, однако они привносят сложность в язык, осо-
бенно когда дело доходит до захваченных переменных. Замыкания всегда запутанны в смысле
точного определения, каким образом разделяется доступная среда, и язык C# в этом отношении
ничем не отличается. Тем не менее, причина, по которой концепция продержалась так долго, связа-
на с тем, что она может сделать код проще для понимания и более непосредственным. Соблюдать
баланс между сложностью и простотой всегда нелегко, и осторожность никогда не будет лишней.
Но со временем вы лучше станете понимать поведение захваченных переменных и работу с ними.
Язык LINQ еще более способствует их использованию, и в современном идиоматическом коде C#
замыкания применяются довольно часто.
Анонимные методы — не единственное изменение в версии C# 2, которое предусматрива-
ет скрытое создание компилятором дополнительных типов и выполнение обходных действий в
отношении переменных, которые кажутся локальными. Намного больше информации будет предо-
ставлено в следующей главе, где вы узнаете, что компилятор может создавать целый конечный
автомат, упрощая тем самым реализацию итераторов.
ГЛАВА 6
В этой главе...
• Реализация итераторов в C# 1
• Итераторные блоки в C# 2
• Пример использования итератора
• Итераторы как сопрограммы
Листинг 6.1. Код, в котором используется новый тип коллекции (пока еще не реализованный)
using System;
using System.Collections;
public class IterationSample : IEnumerable
{
object[] values;
int startingPoint;
public IterationSample(object[] values, int startingPoint)
{
this.values = values;
this.startingPoint = startingPoint;
}
public IEnumerator GetEnumerator()
{
throw new NotImplementedException();
}
}
Хотя метод GetEnumerator() пока не реализован, остальной код готов. А как приступить к
написанию кода GetEnumerator()? Прежде всего, следует понять, что нам необходимо где-то
хранить определенное состояние. Важный аспект шаблона итератора заключается в том, что все
данные не возвращаются за один шаг — клиент запрашивает по одному элементу за раз. Это
означает, что нужно отслеживать, насколько далеко зашло продвижение по массиву. Поддержи-
вающая состояние природа итераторов будет важна, когда мы начнем рассматривать, что делает
компилятор C# 2, поэтому внимательно следите за состоянием, требуемым в данном примере.
Где должно находиться это состояние? Предположим, вы пытаетесь поместить его в класс
IterationSample, обеспечив реализацию этим классом интерфейсов IEnumerator и
IEnumerable. На первый взгляд, это выглядит удачным планом — в конце концов, данные распо-
лагаются в правильном месте, включая начальную точку Метод GetEnumerator() мог бы про-
сто возвращать this. Но с таким подходом связана крупная проблема: если GetEnumerator()
вызывается несколько раз, то должны возвращаться независимые итераторы. Например, для по-
лучения всех возможных пар значений требуется возможность использования двух операторов
foreach, один внутри другого. Эти два итератора должны быть независимыми, что означает необ-
ходимость создания нового объекта при каждом вызове метода GetEnumerator(). Такую функ-
циональность все еще можно было бы реализовать непосредственно внутри IterationSample,
но тогда получился бы класс, не имеющий единственной четкой ответственности, что привело бы
к путанице.
Взамен давайте создадим еще один класс с целью реализации самого итератора. Можно вос-
пользоваться тем фактом, что в языке C# вложенный тип имеет доступ к закрытым членам вклю-
чающего его типа, т.е. допускается сохранить ссылку на родительский экземпляр IterationSample
наряду с состоянием, отражающим количество выполненных до сих пор итераций. Это показано в
листинге 6.3.
Глава 6. Простой способ реализации итераторов 193
}
public bool MoveNext()
{
if (position != parent.values.Length) ❹Увеличение позиции,
если это все еще возможно
{
position++;
}
return position < parent.values.Length;
}
public object Current
{
get
{
if (position == -1 || ❺Предотвращение доступа перед первым
или после последнего элемента
position == parent.values.Length)
{
throw new InvalidOperationException();
}
int index = position + parent.startingPoint; Реализации
❻циклического
index = index % parent.values.Length; возврата
return parent.values[index];
}
}
public void Reset()
{
position = -1; ❼Переход к позиции перед первым элементом
}
}
Что-то слишком много кода понадобилось для решения такой простой задачи! Первым делом
объявляются исходная коллекция значений, по которой производится итерация Ê, и текущая пози-
ция в простом массиве с индексацией, начинающейся с нуля Ë. Чтобы возвратить элемент, индекс
смещается по начальной точке Ï. В соответствие с интерфейсом мы предполагаем, что итератор
должен логически стартовать перед первым элементом Ì, поэтому клиент должен будет вызвать
метод MoveNext() перед обращением к свойству Current в первый раз. Условное инкремен-
тирование в операторе Í делает проверку в строке Î простой и корректной, даже если метод
Глава 6. Простой способ реализации итераторов 194
MoveNext() вызывается снова после того, как он уже сообщил, что доступных данных больше
нет. Для сброса итератора логическая позиция устанавливается опять перед первым элементом Ð.
Большая часть применяемой логики довольно прямолинейна, хотя при этом есть широкий
простор для допущения ошибок диапазона; моя первая реализация не прошла модульные тесты
именно по этой причине. Хорошие новости в том, что код работает, и для завершения примера в
классе IterationSample необходимо лишь реализовать интерфейс IEnumerable:
Итого реализация содержит четыре строки кода, две из которых являются просто фигурными
скобками.
Точнее говоря, это заменяет целый класс IterationSampleIterator — полностью. По
крайней мере, в исходном коде... Позже вы увидите, что именно компилятор сделал “за кулисами”,
а также индивидуальные особенности предоставленной им реализации, но пока давайте уделять
внимание исходному коду.
Метод выглядит совершенно обычным, если не считать использования оператора yield return.
Данный оператор сообщает компилятору C# о том, что это не обычный метод, а метод, реали-
зующий итераторный блок. Метод объявлен как возвращающий тип IEnumerator. Итератор-
ные блоки можно применять только для реализации методов1 , которые имеют возвращаемый тип
IEnumerable, IEnumerator или один из их обобщенных эквивалентов. Типом выдачи итера-
торного блока будет object, если объявленным возвращаемым типом является необобщенный
интерфейс, или аргумент типа, если в качестве возвращаемого типа указан обобщенный интер-
фейс. Например, метод, объявленный как возвращающий IEnumerable<string>, будет иметь
тип выдачи string.
Внутри итераторных блоков не разрешены обычные операторы return — допускаются только
yield return. Все операторы yield return в блоке должны пытаться возвращать значение,
совместимое с типом выдачи блока. В предыдущем примере нельзя было применять оператор
yield return 1;, т.к. метод объявлен как возвращающий IEnumerable<string>.
С операторами yield связано еще несколько ограничений. Оператор yield return не разреше-
но использовать внутри блока try при наличии любых блоков catch, и не допускается применять
оператор yield return или yield break (который мы вскоре рассмотрим) в блоке finally.
Это не означает невозможности использования блоков try/catch или try/finally внутри ите-
раторов, но просто ограничивает то, что можно делать внутри них. О причинах существования
таких ограничений, а также о проектных решениях, положенных в основу итераторов, можно почи-
тать в статьях Эрика Липперта по адресу http://blogs.msdn.com/b/ericlippert/archive
/tags/iterators/.
Когда речь идет об итераторных блоках, важно понимать, что хотя пишется метод, который
выглядит выполняющимся последовательно, в действительности у компилятора запрашивается со-
здание конечного автомата. Это необходимо по той же самой причине, по которой приходилось
прикладывать настолько много усилий при реализации итератора в C# 1 — поскольку вызыва-
ющему коду требуется только один элемент за раз, нужно отслеживать то, что было сделано во
время последнего возврата значения.
Когда компилятор встречает итераторный блок, он создает вложенный тип, предназначенный
для конечного автомата. Этот тип запоминает точное местонахождение внутри блока и значения
локальных переменных (в том числе параметров). Сгенерированный класс отчасти похож на
написанную ранее длинную реализацию тем, что он сохраняет все необходимое состояние в виде
переменных экземпляра. Давайте подумаем о том, что этот конечный автомат должен делать в
плане реализации итератора.
• Всякий раз, когда вызывается метод MoveNext(), он должен выполнять код из метода
GetEnumerator() до тех пор, пока не будет готово к предоставлению следующее значение
1
Или свойств, как вы увидите позже. Тем не менее, итераторный блок нельзя использовать в анонимном методе.
Глава 6. Простой способ реализации итераторов 196
• Он должен знать, когда выдача значений завершена, чтобы метод MoveNext() мог возвра-
тить false.
Второй пункт в этом списке является сложным, т.к. конечный автомат всегда нуждается в
перезапуске кода с точки, которая была достигнута ранее. Отслеживание локальных перемен-
ных (поскольку они присутствуют в методе) реализуется не очень сложно — они представлены
переменными экземпляра в конечном автомате. Аспект перезапуска еще сложнее, но важно осо-
знавать, что в случае, если вы не занимаетесь написанием компилятора С#, то заботиться о том,
как это реализовано, не придется: в соответствие с принципом “черного ящика” все просто рабо-
тает должным образом. Внутрь итераторного блока можно помещать совершенно нормальный код,
и компилятор отвечает за то, что поток выполнения будет точно таким же, как в любом другом
методе. Разница в том, что оператор yield return осуществляет только временный выход из
метода — на самом деле о нем можно думать как о средстве реализации паузы.
Теперь мы приступим к детальным исследованиям потока выполнения в более наглядном виде.
Console.WriteLine("Starting to iterate");
// Начало итерации
while (true)
{
Console.WriteLine("Calling MoveNext()...");
// Вызов MoveNext()
bool result = iterator.MoveNext();
Console.WriteLine("... MoveNext result={0}", result);
// Результат выполнения MoveNext()
if (!result)
{
break;
}
Console.WriteLine("Fetching Current...");
// Извлечение Current
Console.WriteLine("... Current result={0}", iterator.Current);
// Результат Current
}
Код в листинге 6.5 не выглядит идеально, особенно с точки зрения итерации. При нормаль-
ном ходе событий можно было бы просто воспользоваться циклом foreach, но чтобы четко
показать, что происходит и когда, я разбил использование итератора на несколько частей. По
большому счету этот код делает то, что делает цикл foreach, хотя foreach также вызывает
в конце метод Dispose(). Как вскоре будет показано, для итераторных блоков это важно. Как
видите, никаких отличий в синтаксисе внутри метода итератора нет, несмотря на то, что в этот
раз вместо IEnumerator<int> возвращается IEnumerable<int>. Обычно с целью реализа-
ции IEnumerable<T> будет возвращаться только IEnumerator<T>; если из метода необходимо
выдать последовательность, должен возвращаться тип IEnumerable<T>.
Ниже представлен вывод кода из листинга 6.5:
Starting to iterate
Calling MoveNext()...
Start of CreateEnumerable()
About to yield 0
... MoveNext result=True
Fetching Current...
... Current result=0
Calling MoveNext()...
After yield
About to yield 1
... MoveNext result=True
Fetching Current...
... Current result=1
Calling MoveNext()...
After yield
About to yield 2
... MoveNext result=True
Fetching Current...
... Current result=2
Глава 6. Простой способ реализации итераторов 198
Calling MoveNext()...
After yield
Yielding final value
... MoveNext result=True
Fetching Current...
... Current result=-1
Calling MoveNext()...
End of CreateEnumerable()
... MoveNext result=False
В этом выводе следует отметить несколько важных моментов.
• Обратите внимание, что код в методе CreateEnumerable() выполняется до первого обра-
щения к MoveNext().
• Вся реальная работа делается при вызове метода MoveNext(). Во время извлечения свой-
ства Current никакой ваш код не выполняется.
• Код останавливает выполнение на операторе yield return и возобновляет его снова при
следующем вызове MoveNext().
• Можно иметь множество операторов yield return в разных местах метода.
• Код не заканчивается на последнем операторе yield return. Вместо этого вызов метода
MoveNext(), приводящий к концу метода, возвращает false.
Первый момент особенно важен, поскольку он означает, что итераторный блок нельзя исполь-
зовать для кода, который должен быть выполнен немедленно, когда вызывается метод, к примеру,
кода проверки достоверности аргументов. Если поместить обычный код проверки внутрь метода,
реализованного с помощью итераторного блока, то его поведение будет некорректным. В какой-то
момент вы непременно столкнетесь с подобной ситуацией — это исключительно распространенная
ошибка, которую трудно понять, если не думать о том, что именно делает итераторный блок.
Решение данной проблемы будет показано в разделе 6.3.3.
Есть два вопроса, которые пока еще не рассматривались — альтернативный способ прекраще-
ния итерации и работа блоков finally в этой несколько необычной форме выполнения. Давайте
взглянем на них сейчас.
Всегда можно найти способ организации в методе единственной точки выхода, и многие упорно
работают для достижения этой цели2 . Те же самые приемы применимы и к итераторным блокам.
2
Я считаю, что препятствия, которые при этом приходится преодолевать, часто делают код намного более трудным
для чтения, чем наличие множества точек возврата, особенно в случае применения блоков try/finally для очистки
и с учетом возможности возникновения исключений. Идея состоит в том, что это может быть сделано.
Глава 6. Простой способ реализации итераторов 199
Если вам нужно организовать ранний выход, следует обратиться к услугам оператора yield
break. Он фактически завершает итератор, обеспечивая возврат значения false текущим вызо-
вом MoveNext().
В листинге 6.6 демонстрируется использование yield break на примере подсчета до 100 с
остановом в случае нехватки времени. Этот код также иллюстрирует применение параметра метода
в итераторном блоке и доказывает, что имя метода несущественно3 .
}
yield return i;
}
}
...
DateTime stop = DateTime.Now.AddSeconds(2);
foreach (int i in CountWithTimeLimit(stop))
{
Console.WriteLine("Received {0}", i);
Thread.Sleep(300);
}
Обычно в результате запуска кода из листинга 6.6 будет получен вывод, состоящий из при-
мерно семи строк. Цикл foreach завершается совершенно нормально — как и было задумано,
у итератора просто заканчиваются элементы для перебора. Оператор yield break ведет себя
подобно оператору return в нормальном методе.
До сих пор все было просто. Осталось выяснить последний аспект, связанный с потоком вы-
полнения: как и когда выполняются блоки finally?
Мы привыкли, что блоки finally выполняются каждый раз, когда покидается определенная
область действия. Однако итераторные блоки ведут себя совсем не так, как обычные методы. Как
уже было показано, оператор yield return фактически приостанавливает выполнение метода,
а не осуществляет выход из него. Следуя такой логике, вы не должны ожидать выполнения в этот
момент любых блоков finally, и это действительно так. Тем не менее, соответствующие блоки
finally выполняются, когда достигнут оператор yield break, в точности как можно было
ожидать при возвращении из обычного метода4 .
3
Обратите внимание, что методы, принимающие параметры ref или out, не могут быть реализованы в виде
итераторных блоков.
4
Блоки finally также работают ожидаемым образом, когда поток выполнения покидает соответствующую об-
ласть действия, не достигая оператора yield return или yield break. Я сосредоточил здесь внимание на пове-
дении этих операторов yield только потому, что с ними связана новизна и отличия потока выполнения.
Глава 6. Простой способ реализации итераторов 200
}
}
...
DateTime stop - DateTime.Now.AddSeconds(2);
foreach (int i in CountWithTimeLimit(stop))
{
Console.WriteLine("Received {0}", i); // Вывод полученного значения
Thread.Sleep(300);
}
Блок finally в листинге 6.7 выполняется либо когда итераторный блок завершает свою ра-
боту, досчитав до 100, либо из-за достижения установленного лимита времени. (Он также бы
выполнялся в случае генерации исключения в коде.) Но есть и другие способы, посредством кото-
рых можно попытаться избежать обращения к блоку finally... Попробуем проявить коварство.
Вы уже видели, что код в итераторном блоке выполняется только при вызове метода MoveNext().
А что произойдет, если никогда не вызывать MoveNext()? Или если вызвать его несколько раз и
затем прекратить это делать? Давайте изменим вызывающую часть кода в листинге 6.7 следующим
образом:
Глава 6. Простой способ реализации итераторов 201
Здесь производится не ранний останов в коде итератора, а ранний останов в коде, использую-
щем этот итератор. Вывод, возможно, удивит:
Received 1
Received 2
Received 3
Received 4
Returning
Stopping!
Как видите, выполнение кода продолжается даже после достижения оператора return в цикле
foreach. Это не должно происходить, если только не был задействован блок finally — и в
данном случае их два! Вы уже знаете о блоке finally внутри метода итератора, но вопрос в том,
что стало причиной его выполнения.
Ранее я уже давал подсказку — цикл foreach вызывает метод Dispose() на итераторе,
возвращаемом GetEnumerator() в собственном блоке finally (подобно оператору using).
Когда метод Dispose() вызывается на итераторе, созданном с помощью итераторного блока, до
завершения итерации, конечный автомат выполняет любые блоки finally, которые находятся
в области действия во время текущей “приостановки” кода. Это сложное и довольно подробное
объяснение, но результат выразить проще: до тех пор, пока вызывающий код использует цикл
foreach, блок finally работает внутри итераторных блоков так, как требуется.
Можно легко доказать, что причиной такого поведения является вызов метода Dispose(),
применив итератор вручную:
На этот раз строка Stopping! на консоль не выводится. Если явно добавить вызов Dispose(),
то вы снова увидите в выводе дополнительную строку Stopping!. Необходимость в прекращении
итератора до его фактического завершения будет возникать относительно редко, и столь же редко
итерация будет выполняться вручную, а не посредством цикла foreach. Однако когда это все же
делается, нужно не забывать о помещении итератора внутрь оператора using.
Большая часть поведения итераторных блоков уже рассмотрена, но в завершение данного раз-
дела полезно ознакомиться с несколькими странностями, возникающими при работе с текущей
реализацией Microsoft.
Глава 6. Простой способ реализации итераторов 202
Я много трудился над этой областью кода и всегда испытывал неприязнь к такому циклу, но
это было только во время чтения кода вслух другому разработчику как псевдокода, и как-то раз
я понял, что упускал из виду один трюк. Я говорил примерно так “для каждого дня в рамках
расписания...”. Оглядываясь назад, вполне очевидно, что на самом деле я хотел иметь дело с
циклом foreach. (Для вас это может быть очевидным с самого начала, так что прошу прощения,
если это так. Хорошо, что я не могу видеть ваше ухмыляющееся лицо.) Цикл выглядит намного
лучше, если его переписать так:
day = day.AddDays(1))
{
yield return day;
}
}
}
В итоге исходный цикл перемещается в класс расписания, но это нормально. Для него намного
лучше быть инкапсулированным именно здесь, в свойстве, которое просто проходит в цикле по
дням, каждый раз выдавая по одному дню, чем находиться в бизнес-коде, имеющем дело с этими
днями. Если бы требовалось сделать свойство более сложным (к примеру, пропуская выходные
и праздничные дни), то это можно было осуществлять в одном месте и получить преимущества
везде.
Одно такое небольшое изменение привело к серьезному улучшению в плане читабельности
кода. Поскольку это произошло, я остановил рефакторинг коммерческого кода. Я обдумывал вве-
дение типа Range<T> для представления универсального диапазона, но это было нужно лишь в
данной одной ситуации, потому я не считал целесообразным тратить дополнительные усилия на
решение задачи. Оказалось, что это был мудрый ход. В первом издании этой книги я создал имен-
но такой тип, но в нем присутствовали определенные недостатки, которые было трудно устранить
в дружественном для книги стиле. Я существенно его перепроектировал для своей служебной
библиотеки, но все еще испытываю некоторые опасения. Типы вроде него часто выглядят проще,
чем есть на самом деле, и довольно скоро приходится постоянно иметь дело с каким-то крае-
вым случаем. Подробные сведения о сложностях, с которыми я сталкивался, выходят за рамки
материала этой книги, т.к. они больше касаются общего проектирования, чем языка С#, однако
они интересны сами по себе, поэтому я описал их в статье на веб-сайте, посвященном книге
(http://csharpindepth.com/Articles/Chapter6/Ranges.aspx).
Следующий пример является одним из моих любимых — он демонстрирует все, что мне нра-
вится в итераторных блоках.
Специфичными для данной ситуации являются только первая и последняя концепции — управ-
ление жизненным циклом и механизм итерации представляют собой стандартный код. (Во всяком
случае, управление жизненным циклом реализуется в языке C# довольно просто, и все это бла-
годаря операторам using.) Есть два способа улучшить положение дел. Можно было бы восполь-
зоваться делегатом — написать служебный метод, принимающий в качестве параметров средство
чтения и делегат, вызывать этот делегат для каждой строки файла и закрыть средство чтения
в конце метода. Такой подход часто служит примером применения замыканий и делегатов, но
существует альтернатива, которую я нахожу более элегантной и намного лучше вписывающейся
в концепции LINQ. Вместо передачи нужной логики методу в виде делегата можно использовать
итератор для возвращения из файла по одной строке за раз, что позволит организовать обычный
цикл foreach.
Достичь этого можно с помощью полноценного типа, реализующего интерфейс IEnumerable
<string> (для такой цели в моей библиотеке MiscUtil предусмотрен класс LineReader), но
автономный метод в другом классе также будет нормально работать. Он действительно прост, что
подтверждает код в листинге 6.8.
Тело метода ReadLines() в значительной степени совпадает с тем, что было ранее, за ис-
ключением выдачи строки вызывающему коду во время выполнения итерации по коллекции. Как
и до этого, файл открывается, из него читается по одной строке за раз и затем средство чтения
по завершении закрывается, хотя в этом случае концепция “по завершении” более интересна, чем
использование оператора using в нормальном методе, где управление потоком более очевидно.
Вот почему настолько важно, что цикл foreach освобождает итератор — это гарантиру-
ет очистку средства чтения. Оператор using в методе итератора действует в качестве блока
try/finally; этот блок finally будет выполняться либо по достижении конца файла, ли-
бо при вызове метода Dispose() экземпляра IEnumerator<string> где-то на середине пу-
ти. Вызывающий код вполне может неправильно употребить экземпляр IEnumerator<string>,
возвращаемый методом ReadLines(...).GetEnumerator(), и в конечном итоге привести к
Глава 6. Простой способ реализации итераторов 206
утечке ресурсов, но это обычная ситуация с интерфейсом IDisposable — если не вызвать метод
Dispose(), может возникнуть утечка ресурсов. Однако это редко является проблемой, т.к. цикл
foreach обеспечивает правильное поведение. Важно помнить об этом потенциальном неправиль-
ном применении — если вы полагаетесь внутри итератора на какой-то блок try/finally для
выдачи определенного разрешения, которое позже отбирается, то в таком случае действительно
может возникнуть брешь в безопасности.
Этот метод инкапсулирует первые три из перечисленных ранее четырех концепций, но он
несколько ограничен. Аспекты управления временем жизни и итерации разумно объединить вме-
сте, но что, если необходимо прочитать текст не из файла, а из сетевого потока? Или нужно
использовать кодировку, отличную от UTF-8? Первую часть придется поместить обратно под кон-
троль вызывающего кода, и наиболее очевидный подход заключался бы в изменении сигнатуры
метода для приема экземпляра TextReader, например:
Тем не менее, это неудачная идея. Необходимо иметь права на владение средством чтения, что-
бы можно было очищать его удобным для вызывающего кода способом, но сам факт возложения
ответственности за очистку означает и обязанность делать это при условии, что вызывающий
код разумно использует результат. Проблема в том, что если что-то произойдет до первого вызова
метода MoveNext(), то шансов выполнить очистку не будет: никакой ваш код не запустится. Тип
IEnumerable<string> сам по себе не является освобождаемым, однако он хранил бы эту пор-
цию состояния, которая требует освобождения. Еще одна проблема может возникнуть в случае,
если метод GetEnumerator() был вызван дважды: это должно сгенерировать два независимых
итератора, но они будут работать с одним и тем же средством чтения. Снизить риск возникно-
вения такой проблемы можно было бы, изменив возвращаемый тип на IEnumerator<string>,
но это означало бы, что результат не удастся применить в цикле foreach, и по-прежнему не
получилось бы выполнить любой код очистки при отсутствии даже первого обращения к методу
MoveNext(). К счастью, существует обходной путь.
Точно так же как код не должен запускаться немедленно, не требуется немедленное предо-
ставление и средства чтения. Вместо этого нужен способ получения средства чтения, когда оно
необходимо. Можно было бы воспользоваться интерфейсом для представления идеи “я могу предо-
ставить экземпляр TextReader, когда он нужен”, но эта идея интерфейса с единственным ме-
тодом должна обычно приводить к созданию делегата. Взамен я собираюсь немного схитрить,
обратившись к делегату, который является частью .NET 3.5. Он имеет несколько перегруженных
версий с разным количеством параметров типов, но нам необходима только одна из них:
Как видите, этот делегат не принимает параметров, но возвращает результат того же типа,
что и параметр типа. Он имеет сигнатуру классического поставщика или фабрики. В данном
случае нужно получить экземпляр TextReader, поэтому можно применять Func<TextReader>.
Изменения, внесенные в метод, просты (они выделены полужирным):
}
}
В листинге 6.9 приведен полный пример, включающий простую проверку достоверности аргу-
ментов. В нем используется фильтр для отображения всех директив using в исходном файле,
который содержит код самого примера.
Листинг 6.9. Реализация метода Where() языка LINQ с применением итераторных блоков
{
throw new ArgumentNullException();
}
return WhereImpl(source, predicate); ❷ Ленивая обработка данных
}
private static IEnumerable<T> WhereImpl<T>(IEnumerable<T> source,
Predicate<T> predicate)
{
foreach (T item in source)
{
if (predicate(item)) ❸ Проверка текущего элемента на предмет соответствия предикату
{
yield return item;
}
}
}
...
IEnumerable<string> lines = LineReader.ReadLines("../../FakeLinq.cs");
Predicate<string> predicate = delegate(string line)
{ return line.StartsWith("using"); };
foreach (string line in Where(lines, predicate))
{
Console.WriteLine(line);
}
В этом примере реализация разбита на две части: проверка достоверности аргументов и дей-
ствительная бизнес-логика фильтрации. Это немного неуклюже, но совершенно необходимо для
разумной обработки ошибок. Предположим, что вы поместили все в один метод. Что произойдет
при вызове Where<string>(null, null)? Ответ: ничего... или, во всяком случае, желаемое
исключение не будет сгенерировано. Причина кроется в семантике ленивого выполнения итера-
торных блоков: код в теле метода не запускается до тех пор, пока метод MoveNext() не будет
вызван первый раз, как объяснялось в разделе 6.2.2. Обычно проверять предусловия необходимо
энергичным образом — нет никакого смысла в откладывании генерации исключения, т.к. это
только усложнит отладку.
Стандартный обходной путь предусматривает разделение метода на две части, как было пока-
зано в листинге 6.9. Прежде всего, в нормальном методе проверяются аргументы Ê, после чего
производится вызов метода, реализованного с использованием итераторного блока, для ленивой
обработки данных тогда, когда они запрашиваются Ë.
Глава 6. Простой способ реализации итераторов 209
Сам итераторный блок предельно прямолинеен: для каждого элемента в исходной коллекции
осуществляется проверка на предмет соответствия предикату Ì и выдача значения, если оно
является подходящим. Если совпадения нет, проверяется следующий элемент и так до тех пор,
пока не будет найден такой, который совпадает, или элементы не закончатся. Хотя это просто,
но реализацию на C# 1 было бы намного труднее разбирать (и, разумеется, она не была бы
обобщенной).
В заключительной порции кода, демонстрирующей метод в действии, для предоставления дан-
ных применяется последний пример — в этом случае в качестве данных выступает исходный
код самой реализации. Предикат просто проверяет строку на предмет того, начинается ли она
с using — конечно, с тем же успехом он мог бы содержать намного более сложную логику.
Отдельные переменные для данных и предикат были созданы всего лишь для более ясного фор-
матирования, но их можно было бы записать и внутристрочно. Важно отметить главное отличие
между данным примером и его эквивалентом, который можно было бы построить с помощью
методов File.ReadAllLines() и Array.FindAll<string>(). Эта реализация полностью
соответствует характеристикам ленивого и потокового выполнения. В памяти постоянно должна
находиться только одна строка исходного файла. Разумеется, это не играет роли в данном случае,
когда файл имеет небольшой размер, но преимущества такого подхода легко заменить в случае,
скажем, многогигабайтного журнального файла.
Надеюсь, что приведенные выше примеры дали некоторое представление о важности итератор-
ных блоков, а также, возможно, пробудили желание побыстрее узнать дополнительные сведения
о LINQ. Однако перед этим мне хочется немного запудрить вам мозги и представить совершенно
странный (но действительно компактный) случай использования итераторов.
Понять этот код очень легко. Но необходимо учитывать, что если каждый запрос занимает
2 секунды, то целая операция потребует 6 секунд и заблокирует поток на все время своего вы-
полнения. Если нужно масштабировать код для параллельной обработки сотен тысяч запросов,
возникает проблема.
А теперь давайте рассмотрим довольно простую асинхронную версию, в которой поток не
блокируется, когда ничего не происходит6 , и везде, где возможно, применяются параллельные
вызовы:
Приведенный код намного труднее для чтения и понимания — и это лишь простая версия.
Координацию двух параллельных вызовов удалось достичь так легко только потому, что отсут-
ствовала необходимость в передаче любого другого состояния, но даже здесь она не идеальна.
Если обращение к службе биржевых котировок (StockService) выполнится быстро, поток из
пула все равно будет блокироваться из-за ожидания, пока завершится запрос к базе данных. А
еще важнее то, что при этом трудно понять происходящее, поскольку код перескакивает с метода
на метод.
К этому моменту может возникнуть вопрос о том, как в общую картину могут вписаться итера-
торы. Итераторные блоки, предоставляемые C# 2, фактически позволяют приостанавливать теку-
щее выполнение в определенных точках потока через блок и затем возвращаться в те же самые ме-
ста с тем же состоянием. Талантливые проектировщики библиотеки CCR осознали, что им нужен
стиль кодирования, предусматривающий передачу признака продолжения. Системе необходимо
сообщить о том, что есть набор операций, которые требуется выполнить, включая асинхронный за-
пуск других операций, но затем можно благополучно ожидать завершения асинхронных операций,
прежде чем продолжать дальше. Это делается за счет предоставления библиотеке CCR реализации
6
Ладно, по большей части. Как вскоре будет показано, эта версия по-прежнему может быть неэффективной.
Глава 6. Простой способ реализации итераторов 211
Запутались? Когда я впервые увидел этот код, то определенно запутался, но теперь я востор-
гаюсь его лаконичностью. Библиотека CCR приводит в действие ваш код (посредством вызова
метода MoveNext() на итераторе), и выполнение происходит вплоть до первого оператора yield
return, включая его. Метод CcrCheck() внутри типа AuthService запускает асинхронный
запрос, а библиотека CCR ожидает (не используя отдельный поток) его завершения, вызывая
предоставленный делегат для обработки результата. Затем MoveNext() вызывается снова, и ваш
метод продолжает выполнение. На этот раз запускаются два запроса параллельно, а библиотеке
CCR предлагается вызвать другой делегат с передачей ему результатов выполнения обеих опера-
ций, когда они обе завершатся. Наконец, метод MoveNext() вызывается последний раз, и дело
доходит до завершения обработки запросов.
Хотя асинхронная версия заметно сложнее синхронной, она по-прежнему содержит один ме-
тод, выполняется в том порядке, в каком написан код, и сам метод может хранить состояние (в
локальных переменных, которые становятся состоянием внутри дополнительного типа, сгенериро-
ванного компилятором). Все выполняется полностью асинхронно с использованием минимально
необходимого количества потоков. В коде не была показана обработка ошибок, но она также до-
ступна, причем в стиле, который вынуждает обдумывать возможности возникновения проблем в
подходящих местах кода.
Я умышленно не погружаюсь здесь в детали класса Arbiter, интерфейса ITask и тому
подобного. Вдобавок в этом разделе я не пытаюсь рекламировать библиотеку CCR, хотя читать и
экспериментировать с ней исключительно интересно; я полагаю, что асинхронные функции в C# 5
окажут намного большее влияние на подавляющее большинство разработчиков. Моей целью было
показать, что итераторы могут применяться в радикально отличающихся контекстах, которые
имеют мало общего с традиционными коллекциями. В основе этого использования лежит идея
конечного автомата; двумя запутанными аспектами асинхронной разработки являются поддержка
состояния и действительная приостановка до тех пор, пока не произойдет что-то интересное.
Итераторные блоки вполне естественно приспособлены для устранения обеих этих проблем, хотя
в главе 15 вы увидите, что целевая языковая поддержка позволяет получать намного более ясные
решения.
Глава 6. Простой способ реализации итераторов 212
6.5 Резюме
В C# косвенным образом поддерживается много шаблонов, в плане осуществимости их реали-
зации средствами языка. Однако лишь относительно небольшое число шаблонов поддерживаются
напрямую в терминах языковых возможностей, специально ориентированных на конкретные шаб-
лоны. В C# 1 шаблон итератора напрямую поддерживался с точки зрения вызывающего кода, но
не коллекции, по которой производится итерация. Написание корректных реализаций интерфейса
IEnumerable было затратным по времени и подверженным ошибкам, к тому же неинтересным. В
C# 2 компилятор делает всю рутинную работу за вас, строя конечный автомат, чтобы стравиться
с природой обратных вызовов, которая присуща итераторам.
Следует отметить, что итераторные блоки имеют один общий аспект с анонимными методами,
рассмотренными в главе 5, невзирая на то, что их действительные возможности сильно отличают-
ся. В обоих случаях могут быть сгенерированы дополнительные типы, а исходный код подвергает-
ся потенциально сложной трансформации. Сравните это с C# 1, где большинство трансформаций
для синтаксического сахара (самыми очевидными примерами являются lock, using и foreach)
были прямолинейными. Такая тенденция более интеллектуальной компиляции будет продолжена
в почти каждом аспекте версии C# 3.
В этой главе была показана одна порция функциональности, связанной с LINQ: фильтрация
коллекции. Интерфейс IEnumerable<T> является одним из наиболее важных типов в LINQ, но
даже если нужно записать собственные операции LINQ на основе LINQ to Objects7 , вы будете
неизменно благодарны команде разработчиков C# за включение в язык итераторных блоков.
В дополнение к нескольким реалистичным примерам использования итераторов было показано,
как одна конкретная библиотека применяла их довольно радикальным способом, который имеет
мало общего с тем, что приходит на ум, когда речь идет об итерации по коллекции. Полезно
помнить о том, что многие языки сталкивались с подобного рода проблемой ранее — в вычисли-
тельной технике для концепций такого вида применяется термин сопрограмма. На эти концепции
ссылаются в наборе инструментов для разработки трехмерных игр под названием Unity, где они
используются для обеспечения асинхронности. Исторически разные языки предлагали поддержку
указанных концепций в большей или меньшей степени, иногда с подходящими уловками для их
моделирования. Например, у Саймона Тэтхема есть великолепная статья о том, как реализовывать
сопрограммы даже на языке С, если вы готовы кое в чем отступить от стандартов кодирования
(его статья “Coroutines in С” (“Сопрограммы в С”) доступна по адресу http://mng.bz/H8YX).
Вы увидите, что C# 2 упрощает написание и работу с сопрограммами.
Теперь, когда вы ознакомились с рядом крупных и иногда трудно понимаемых изменений
в языке, сосредоточенных вокруг новых основных возможностей, в следующей главе вводятся
новые правила. В ней будет описано несколько небольших изменений, которые делают работу с
C# 2 более удобной по сравнению с предшествующей версией. Проектировщики извлекли урок из
прошлых критических замечаний и построили язык, который имеет меньше острых углов, больше
возможностей разрешения ситуаций с затруднениями при обеспечении обратной совместимости и
лучшее восприятие генерируемого кода. Каждая возможность относительно проста, но их довольно
много.
7
Это выглядит не таким уж обескураживающим, как может показаться на первый взгляд. Несколько руководящих
принципов по этой теме будут представлены в главе 12.
ГЛАВА 7
В этой главе...
• Частичные типы
• Статические классы
• Директивы pragma
• Дружественные сборки
До сих пор вы видели четыре крупнейших новых средства в C# 2: обобщения, типы, допуска-
ющие null, усовершенствованные делегаты и итераторные блоки. Каждое из них ориентировано
на удовлетворение довольно сложной потребности, поэтому они рассматривались относительно
подробно. Оставшиеся новые возможности C# 2 срезают несколько острых углов C# 1. Они
представляют собой мелочи, которые проектировщики языка решили подправить — либо области,
где требовались усовершенствования языка ради себя самого, либо там, где особенности работы с
генерацией кода и машинным кодом могли быть улучшены.
На протяжении длительного времени в Microsoft получали множество откликов сообщества
программистов на C# (и, конечно же, собственных разработчиков) относительно областей, в ко-
торых язык C# не был настолько блестящим, насколько мог быть. Ситуацию облегчили небольшие
изменения, внесенные в C# 2 наряду с крупными изменениями.
Ни одно из рассматриваемых в этой главе средств не является особенно трудным, так что мы
пройдем их довольно быстро. Однако их важность нельзя недооценивать. Только тот факт, что
тема может быть раскрыта на нескольких страницах, не означает ее бесполезность. Скорее всего,
вы будете пользоваться этими средствами на регулярной основе. Ниже приведен краткий обзор
средств, описанных в данной главе, вместе с областями их применения.
Глава 7. Заключительные штрихи C# 2: финальные возможности 214
• Частичные типы. Возможность написания кода для типа в нескольких файлах исходного
кода. Это особенно удобно для типов, часть кода которых генерируется автоматически, а
другая часть пишется вручную.
• Псевдонимы пространств имен. Пути выхода из сложных ситуаций, когда имена типов не
являются уникальными.
• Буферы фиксированного размера. Больший контроль над тем, как структуры обрабатыва-
ют массивы в небезопасном коде.
В этом разделе мы также обсудим частичные методы, которые уместны только в частичных
типах и позволяют развитый и эффективный способ добавления вручную написанных привязок
в автоматически генерируемый код. В действительности данное средство относится к версии C#
3 (на этот раз оно основано на отзывах о C# 2), но его логичнее обсуждать при исследовании
частичных типов, а не ждать следующей части книги.
Example1.сs Example2.сs
partial class Example partial class Example
{ {
void FirstMethod() void SecondMethod()
{ {
SecondMethod(); ThirdMethod();
} }
void ThirdMethod() }
{
}
}
Рис. 7.1. Код в частичных типах имеет доступ ко всем членам типа
независимо от того, в каком из файлов они находятся
Не допускается записывать одну часть кода члена в одном файле, а другую во втором — каж-
дый отдельный член должен быть реализован полностью внутри собственного файла. Например,
невозможно начать метод в одном файле и завершить его в другом1 . С объявлениями типа связа-
но несколько очевидных ограничений — объявления должны быть совместимыми. В любом файле
можно указывать интерфейсы, предназначенные для реализации (и они не обязаны быть реали-
зованными в этом файле), в любом файле может быть задан базовый тип, а также могут быть
указаны ограничения на параметре типа. Но если в нескольких файлах указаны базовые типы, то
они должны быть одинаковыми, а если установлены ограничения для типов, то эти ограничения
должны быть идентичными. В листинге 7.1 демонстрируется пример предлагаемой гибкости (хотя
код в нем не делает ничего такого, что было бы даже отдаленно полезным).
// Example1.cs
using System;
partial class Example<TFirst, TSecond>
: IEquatable<string> ❶ Указание интерфейса и ограничения параметра типа
1
Здесь имеется одно исключение: частичные типы могут содержать вложенные частичные типы, распространяю-
щиеся по тому же набору файлов.
Глава 7. Заключительные штрихи C# 2: финальные возможности 216
{
return false;
}
}
// Example2.cs
using System;
partial class Example<TFirst, TSecond>
: EventArgs, IDisposable ❸ Указание базового класса и интерфейса
{
public void Dispose() ❹ Реализация IDisposable
{
}
}
Я подчеркиваю, что код в листинге 7.1 предназначен единственно для целей обсуждения того,
что является допустимым в объявлении — используемые в нем типы выбраны только из-за удоб-
ства и известности. Как видите, оба объявления (Ê и Ì) вносят свой вклад в список интерфейсов,
которые должны быть реализованы. В данном примере внутри каждого файла реализованы ин-
терфейсы, которые в нем же и объявлены, и это распространенный сценарий. Тем не менее, было
бы вполне законно перенести реализацию IDisposable Í в файл Example1.cs, а реализацию
IEquatable<string> Ë — в файл Example2.cs. Я воспользовался возможностью указания
интерфейсов отдельно от реализации, инкапсулируя методы с одной и той же сигнатурой, гене-
рируемые множеством разных типов, внутри интерфейса. Генератору кода ничего не известно об
интерфейсе, поэтому он также не знает, как объявить о том, что тип его реализует.
Ограничения типа указаны только в первом объявлении Ê, а базовый класс — только во втором
Ì. Если бы в первом объявлении Ê был задан базовый класс, им должен был бы стать EventArgs,
а если во втором объявлении были указаны ограничения типа, то они должны были бы в точности
совпадать с теми, что определялись в первом объявлении. В частности, во втором объявлении
нельзя указывать ограничение типа для TSecond, даже притом, что оно не упоминалось первым.
Оба типа должны иметь одинаковый модификатор доступа, если он есть — к примеру, невозможно
делать одно объявление internal, а другое public. По существу правила объединения файлов
в большинстве случаев допускают гибкость, приветствуя согласованность.
Для типов, определенных в единственном файле, инициализация переменных-членов и стати-
ческих переменных гарантированно происходит в порядке их нахождения в файле, но в случае
нескольких файлов какой-либо порядок не обеспечивается. Прежде всего, не следует полагаться
на порядок объявления внутри файла, т.к. это спровоцирует появление в вашем коде трудноулови-
мых ошибок, если разработчик решит “безвредно” изменить положение к лучшему — поэтому по
возможности стоит избегать такой ситуации. Особенно это касается частичных типов.
Располагая сведениями о том, что можно делать, а что нельзя, давайте внимательнее посмот-
рим, почему это может понадобиться.
Глава 7. Заключительные штрихи C# 2: финальные возможности 217
GuiPage.xaml.cs Схема/модель
(код С#, GuiPage.xaml (базы данных,
написанный (код XAML) XML и т.д.)
вручную)
Customer.cs GeneratedEntities.cs
GuiPage.g.cs
(код С#, написанный (код С#, включающий
(код С#)
вручную) частичный класс Customer)
Компиляция Компиляция
кода С# кода С#
Тип GuiPage Тип Customer
(часть сборки) (часть сборки)
Существует еще один, несколько отличающийся случай использования частичных типов — со-
действие рефакторингу. Временами тип становится слишком крупным и предполагает чересчур
большое количество ответственностей. Первый шаг разбиения такого раздутого типа на неболь-
шие, более ясные типы предусматривает превращение его в частичный тип, разнесенный по двум
и более файлам. Это может быть сделано безо всякого риска и в экспериментальной манере, с
перемещением методов между файлами до тех пор, пока каждый файл не получит единствен-
ную ответственность. Несмотря на то что следующий шаг разбиения типа по-прежнему далек от
автоматического, увидеть конечную цель должно быть намного легче.
Стоит упомянуть о последнем случае применения частичных типов — модульном тестировании.
Набор модульных тестов для класса часто становится намного больше самой реализации. Один из
способов разделения этих тестов на осмысленные порции заключается в использовании частичных
типов. Можно по-прежнему запускать все тесты для типа за один присест (т.к. все еще имеется
единственный тестовый класс), но тесты для разных областей функциональности воспринимаются
проще, когда они находятся в разных файлах. За счет редактирования вручную файла проекта
можно даже обеспечить такое же поведение с раскрытием родительских/дочерних областей кода
в окне Solution Explorer (Проводник решения), которое доступно в случае применения частичных
типов в сгенерированном коде Visual Studio. Конечно, это дело вкуса, но лично я нахожу такой
подход к управлению тестами весьма удобным.
Когда частичные типы впервые появились в C# 2, поначалу никто в точности не знал, как ими
пользоваться. Почти немедленно была затребована возможность предоставления дополнительного
кода для вызываемых сгенерированных методов. Это было решено в версии C# 3 введением
частичных методов.
Глава 7. Заключительные штрихи C# 2: финальные возможности 219
// Generated.cs
using System;
partial class PartialMethodDemo
{
public PartialMethodDemo()
{
OnConstructorStart();
Console.WriteLine("Generated constructor");
OnConstructorEnd();
}
partial void OnConstructorStart();
partial void OnConstructorEnd();
}
// Handwritten.cs
using System;
partial class PartialMethodDemo
{
Глава 7. Заключительные штрихи C# 2: финальные возможности 220
В листинге 7.2 легко заметить, что частичные методы объявляются почти как абстрактные
методы: за счет предоставления сигнатуры без реализации, но только на этот раз применяет-
ся модификатор partial. Аналогично, действительные реализации просто имеют модификатор
partial, но в остальном они выглядят как нормальные методы.
Вызов конструктора PartialMethodDemo без параметров в результате приведет к выводу на
консоль строки Generated constructor и затем сроки Manual code. Если вы просмотрите
код IL для конструктора, то не увидите вызова метода OnConstructorStart(), т.к. он больше
не существует — в скомпилированном типе вы не обнаружите никаких его следов.
Поскольку метод может не существовать, частичные методы должны иметь возвращаемый
тип void и не могут принимать параметры out. Они должны быть закрытыми, но могут быть
статическими и/или обобщенными. Если метод не реализован в одном из файлов, вызывающий
его оператор полностью удаляется, включая любые оценки аргументов.
Если с оценкой любого аргумента связан побочный эффект, который должен произойти неза-
висимо от того, реализован ли частичный метод, то такая оценка должна выполняться отдельно.
Например, предположим, что имеется следующий код:
LogEntity(LoadAndCache(id));
Здесь LogEntity() является частичным методом, а метод LoadAndCache() загружает сущ-
ность из базы данных и помещает ее в кеш. Вместо этого кода может возникнуть желание записать
так:
MyEntity entity = LoadAndCache(id);
LogEntity(entity);
Таким образом, сущность загружается и кешируется независимо от того, предоставлена ли
реализация для метода LogEntity(). Разумеется, если сущность может быть загружена с экви-
валентными затратами позже или это может даже не потребоваться, необходимо оставить первую
форму оператора, избегая нежелательной загрузки в определенных случаях.
По правде говоря, если только вы не занимаетесь написанием собственных генераторов кода,
то, скорее всего, вы будете чаще реализовывать частичные методы, чем объявлять и вызывать их.
В случае только реализации заботиться о проблемах оценки аргументов не придется.
Подводя итог вышесказанному, частичные методы в C# 3 позволяют сгенерированному коду
широко взаимодействовать с кодом, написанным вручную, без потери производительности в ситуа-
циях, когда такое взаимодействие не требуется. Это естественное продолжение средства частичных
типов C# 2, которое поддерживает намного более продуктивные отношения между генераторами
кода и разработчиками.
Возможность, рассматриваемая следующей, является совершенно другой. Она представляет
собой способ сообщения компилятору о предполагаемой природе типа, чтобы он мог выполнять
больший объем проверок самого типа и любого кода, использующего тип.
Каждый разработчик имеет свои вспомогательные классы. Мне не приходилось видеть хоть
сколько-нибудь значительный проект на Java или С#, в котором бы не присутствовал хотя бы
один класс, состоящий исключительно из статических методов. Классическим примером может
служить тип со вспомогательными методами для обработки строк, которые выполняют отмену
символов, обращение порядка, интеллектуальную замену — словом все, что угодно. В рамках
инфраструктуры таким примером является класс System.Math.
Ниже перечислены ключевые особенности вспомогательного класса.
Последние два пункта необязательны, и при отсутствии видимых извне конструкторов (в том
числе защищенных) класс в любом случае становится запечатанным. Тем не менее, оба эти пункта
позволяют дополнительно прояснить предназначение класса.
В листинге 7.3 приведен пример вспомогательного класса в версии C# 1. После этого мы
посмотрим, какие улучшения предлагает версия C# 2.
{
private NonStaticStringHelper() ❷ Предотвращение создания экземпляров в другом коде
{
}
public static string Reverse(string input) ❸ Все методы являются статическими
{
char[] chars = input.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
}
Класс запечатан Ê, поэтому наследовать другие классы от него нельзя. Наследование предпо-
лагает специализацию, но специализировать здесь нечего, т.к. все члены являются статическими
Ì кроме закрытого конструктора Ë. На первый взгляд этот конструктор выглядит странным —
зачем он вообще нужен, если он закрытый и никогда не будет применяться? Причина в том, что
если не предоставить конструктор для класса, то компилятор C# 1 всегда создаст стандартный
конструктор, который будет открытым и не принимающим параметров. В данном случае лю-
бые видимые извне конструкторы не должны существовать, поэтому приходится объявлять такой
закрытый конструктор.
Глава 7. Заключительные штрихи C# 2: финальные возможности 222
Описанный шаблон работает достаточно хорошо, но версия C# 2 делает его явным и активно
предотвращает его некорректное использование. Прежде всего, мы рассмотрим изменения, которые
понадобится внести в код листинга 7.3 для его превращения в надлежащий статический класс,
как определено в C# 2. В листинге 7.4 видно, что для этого требуется совсем немного.
using System;
public static class StringHelper
{
public static string Reverse(string input)
{
char[] chars = input.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
}
На этот раз в объявлении класса применяется модификатор static вместо sealed, а кон-
структор вообще не включен — это единственное отличие в коде. Компилятору C# 2 известно,
что статический класс не должен иметь какие-либо конструкторы, поэтому он не предоставляет
стандартный конструктор.
На самом деле компилятор принудительно применяет к определению класса несколько ограни-
чений, которые описаны ниже.
• Он не может быть объявлен как абстрактный (abstract) или запечатанный (sealed), хотя
неявно является тем и другим.
string name;
public string Name
{
get { return name; }
private set
{
// Проверка достоверности, фиксация в журнале и т.д.
name = value;
}
}
В этом случае свойство Name поддерживает только чтение для всех остальных типов2 , но
внутри типа, в котором оно определено, можно использовать знакомый синтаксис для установки
указанного свойства. Аналогичный синтаксис предусмотрен также для индексаторов. Средство
2
Кроме вложенных типов, которые всегда имеют доступ к закрытым членам включающих их типов.
Глава 7. Заключительные штрихи C# 2: финальные возможности 224
установки можно было бы сделать более открытым, чем средство получения (например, защи-
щенное средство получения и открытое средство установки), но это достаточно редкая ситуация,
подобно тому, как свойства, допускающие только запись, встречаются намного реже, чем свой-
ства, предназначенные только для чтения.
Во всех других местах кода C# в качестве стандартного модификатора доступа в любой конкретной
ситуации выбирается самый закрытый модификатор из числа возможных. Например, если что-то
может быть объявлено как закрытое, оно таким и будет по умолчанию, если не указаны какие-либо
модификаторы доступа. Это хороший элемент проектного решения, заложенного в язык, т.к. его
трудно непредумышленно нарушить; если вы хотите, чтобы что-то было более открытым, чем есть
на самом деле, то заметите это во время попытки его применения. Но если вы непреднамеренно
сделаете что-то слишком открытым, то компилятор не сможет помочь в выявлении этой проблемы.
Указание модификатора доступа для средства получения или установки является исключением из
данного правила — если ничего не объявлено, средство получения или установки получит тот же
уровень доступа, как у самого свойства, к которому оно относится.
Обратите внимание, что объявить само свойство закрытым и делать открытым его средство
получения нельзя — отдельное средство получения или установки может быть только более за-
крытым, чем свойство. Кроме того, модификатор доступа не допускается указывать одновременно
для средства получения и средства установки — это выглядело бы нелепым, поскольку может ока-
заться так, что объявленное свойство будет более открытым, чем оба модификатора в средствах
доступа.
Эта помощь в отношении инкапсуляции крайне приветствуется. К сожалению, в C# 2 по-
прежнему нет ничего такого, что бы препятствовало обходу свойства остальным кодом класса
и работе непосредственно с поддерживающим полем. Как будет показано в следующей главе, в
версии C# 3 эта проблема исправлена в одном конкретном случае, но не в общем.
А теперь мы оставим средство, которое может применяться регулярно, и обратимся к сред-
ству, которого вы будете стараться избегать в большинстве ситуаций. Оно позволяет коду быть
абсолютно явным в отношении ссылок на используемые в нем типы, но за счет значительного
снижения читабельности.
using System;
using WinForms = System.Windows.Forms;
using WebForms = System.Web.UI.WebControls;
class Test
{
static void Main()
{
Console.WriteLine(typeof(WinForms.Button));
Console.WriteLine(typeof(WebForms.Button));
}
}
Код из листинга 7.5 компилируется без ошибок или предупреждений, хотя все еще не выглядит
настолько симпатичным, как мог бы в случае работы только с одним типом Button. Тем не ме-
нее, существует проблема — что если кто-то определит тип или пространство имен под названием
WinForms или WebForms? Компилятору не известно предназначение типа WinForms.Button
и чему отдать предпочтение — типу или пространству имен либо псевдониму. Необходимо иметь
возможность сообщить компилятору, что он должен трактовать WinForms как псевдоним, несмот-
ря на то, что это имя доступно где-то в другом месте.
Глава 7. Заключительные штрихи C# 2: финальные возможности 226
Для этой цели в C# 2 был введен синтаксис уточнителя пространства имен ::, использо-
вание которого демонстрируется в листинге 7.6.
Листинг 7.6. Применение :: для сообщения компилятору о том, что он должен использовать
псевдонимы
using System;
using WinForms = System.Windows.Forms;
using WebForms = System.Web.UI.WebControls;
class WinForms {}
class Test
{
static void Main()
{
Console.WriteLine(typeof(WinForms::Button));
Console.WriteLine(typeof(WebForms::Button));
}
}
Листинг 7.7. Использование псевдонима глобального пространства имен для точного указа-
ния желательного типа
using System;
class Configuration {}
namespace Chapter7
{
class Configuration {}
class Test
{
Глава 7. Заключительные штрихи C# 2: финальные возможности 227
Большая часть кода в листинге 7.7 просто воспроизводит ситуацию, а особый интерес представ-
ляют три строки в методе Main(). Первая строка выводит на консоль Chapter7.Configuration,
поскольку компилятор распознает Configuration как этот тип, прежде чем перейти к корню
иерархии пространств имен. Вторая строка отражает тот факт, что тип должен находиться в гло-
бальном пространстве имен, поэтому она просто выводит на консоль Configuration. Третья
строка была включена для демонстрации того, что с применением псевдонима глобального про-
странства имен можно по-прежнему ссылаться на типы внутри пространств имен, но вы должны
указывать полностью уточненное имя.
В данный момент можно достичь любого уникально именованного типа, при необходимости
используя глобальное пространство имен. Если вам когда-либо придется строить генератор, по-
рождающий код, который не обязательно должен быть читабельным, можете обильно пользоваться
этим средством, чтобы обеспечить ссылку на корректный тип вне зависимости от существования
во время компиляции любых других типов. Но что делать, если имя типа не является уникальным
даже после указания его пространства имен? Дело принимает другой оборот...
Листинг 7.8. Работа с разными типами с одним и тем же именем, находящимися в разных
сборках
class Test
{
static void Main()
{
Console.WriteLine(typeof(FD.Example)); ❸ Использование псевдонима
пространства имен
Console.WriteLine(typeof(SecondAlias::Demo.Example)); ❹ Использование
внешнего
псевдонима
напрямую
}
}
Код в листинге 7.8 довольно прямолинеен. Сначала вводятся два внешних псевдонима Ê.
После этого их можно использовать либо через псевдонимы пространств имен (Ë и Ì), ли-
бо напрямую Í. На самом деле обычная директива using без псевдонима (такая как using
FirstAlias::Demo;) позволила бы применять имя Example, вообще никак не уточняя его.
Один внешний псевдоним способен покрывать несколько сборок, а множество внешних псевдони-
мов могут ссылаться на одну и ту же сборку, хотя я хорошо бы подумал, прежде чем воспользо-
ваться любой из этих возможностей, а в особенности их комбинацией.
Чтобы указать внешний псевдоним в среде Visual
Studio, просто выберите ссылку на сборку в окне Solution Properti
es
Explorer и измените значение в поле Aliases (Псевдони- Fir
s tReferencePr
operti
es
мы) окна Properties (Свойства), как показано на рис.
7.3.
Надеюсь, мне не придется убеждать вас в том, что Mi sc
ситуаций подобного рода следует избегать всегда, когда (Name ) Fi
rs
t
Ali
ases Fi
rst
Alias
только возможно. Это может понадобиться при взаимо-
CopyL ocal Tr
ue
действии со сборками от независимых разработчиков, в
Cult
ure
которых случайно применяются одинаковые полностью
Decri
ption
уточненные имена типов, когда иной способ их исполь- Fil
eType Ass
embl y
зования отсутствует. Однако при наличия большего кон-
Рис. 7.3. Часть окна Properties среды
троля над именованием удостоверьтесь, что выбираемые
Visual Studio 2010, отображающая внешний
имена никогда не приведут к возникновению описанных
псевдоним FirstAlias для ссылки на
выше ситуаций.
сборку First.dll
Возможность, рассматриваемая следующей, по боль-
шому счету является метасредством. Точная предлагаемая им функциональность зависит от при-
меняемого компилятора, поскольку его назначение — управлять возможностями, специфичными
для компилятора. Мы будет уделять внимание компилятору от Microsoft.
Глава 7. Заключительные штрихи C# 2: финальные возможности 229
Так выглядит вывод компилятора командной строки. В окне Error List (Список ошибок) сре-
ды Visual Studio можно видеть ту же самую информацию (плюс название проекта, в котором
находится класс) за исключением номера предупреждения (CS0169). Чтобы найти этот номер,
понадобится либо выбрать предупреждение и просмотреть справку по нему, либо заглянуть в ок-
но Output (Вывод), где отображается полный текст. Номер предупреждения необходим для того,
чтобы обеспечить компиляцию кода без выдачи такого предупреждения, что и сделано в листинге
7.10.
Глава 7. Заключительные штрихи C# 2: финальные возможности 230
Код в листинге 7.10 особых пояснений не требует — первая директива pragma отключает
выдачу указанного предупреждения, а вторая восстанавливает ее. Установившаяся практика пред-
полагает отключение выдачи предупреждений на как можно более короткий период, чтобы не
пропустить те из них, которые действительно должны быть учтены путем исправления кода. Если
нужно отключить или восстановить выдачу нескольких предупреждений в одной строке, укажите
желаемые номера предупреждений в виде списка с разделителями-запятыми. Если номера пре-
дупреждений вообще не указаны, в результате отключается или восстанавливается выдача всех
предупреждений, но в почти любом вообразимом сценарии это совершенно неприемлемо.
using System;
using System.Runtime.InteropServices;
struct COORD
{
public short X, Y;
}
Struct SMALL_RECT
{
public short Left, Top, Right, Bottom;
}
unsafe struct CONSOLE_SCREEN_BUFFER_INFOEX
{
Глава 7. Заключительные штрихи C# 2: финальные возможности 232
// Компилируется в Source.dll
using System.Runtime.CompilerServices;
[assembly:InternalsVisibleTo("FriendAssembly")] Предоставление
дополнительного доступа
public class Source
{
internal static void InternalMethod() {}
public static void PublicMethod() {}
}
// Компилируется в FriendAssembly.dll
public class Friend
{
static void Main()
{
Source.InternalMethod(); Использование дополнительного доступа
внутри сборки FriendAssembly
3
Использование рефлексии во время выполнения с подходящими правами доступа в расчет не принимается.
Глава 7. Заключительные штрихи C# 2: финальные возможности 234
Source.PublicMethod();
}
}
// Компилируется в EnemyAssembly.dll
public class Enemy
{
static void Main()
{
// Source.InternalMethod(); ❶ Сборка EnemyAssembly не имеет специального доступа
}
}
Например, предположим, что для выяснения открытого ключа подписанной сборки Friend
Assembly.dll применяется следующая командная строка, которая дает показанный ниже вывод
(слегка модифицированный и отформатированный согласно требованиям печатной страницы):
Тогда исходный код класса Source должен был бы содержать такой атрибут:
[assembly:InternalsVisibleTo("FriendAssembly,PublicKey=" +
"0024000004800000940000000602000000240000525341310004000001" +
"000100a51372c81ccfb8fba9c5fb84180c4129e50f0facdce932cf31fe" +
"563d0fe3cb6bld5129e28326060a3a539f287aaf59affc5aabc4d8f981" +
"ela82479ab795f410eab22e3266033c633400463ee7513378bb4ef41fc" +
"0cae5fb03986dl33677c82a865b278c48d99dc251201b9c43edd7bedef" +
"d4b5306efd0dec7787ec6b664471c2")]
К сожалению, необходимо либо указывать открытый ключ в одной строке, либо использовать
конкатенацию строк — пробельные символы в открытом ключе вызовут ошибку компиляции.
Было бы намного лучше, если бы вместо полного ключа допускалось указывать только маркер, но
к счастью это уродство обычно ограничивается файлом AssemblyInfо.cs, так что часто видеть
его не придется.
Теоретически возможно иметь неподписанную исходную сборку и подписанную дружествен-
ную сборку. На практике это не особенно полезно, т.к. дружественной сборке обычно нужна
ссылка на исходную сборку, а ссылаться на неподписанную сборку из подписанной не разреше-
но. Аналогично, в подписанной сборке нельзя указывать неподписанную дружественную сборку,
поэтому обычно если одна из сборок подписана, приходится подписывать и другую.
7.8 Резюме
На этом обзор новых возможностей C# 2 завершен. Темы, рассмотренные в главе, разделены
на две широких категории: улучшения в стиле “хорошо иметь”, которые упрощают разработку, и
средства с характеристикой “надеюсь, что не понадобится”, которые могут вывести из запутанных
ситуаций, когда в них возникает потребность. Проводя аналогию между C# 2 и улучшениями в
доме, следует отметить, что крупные средства, описанные в предшествующих главах, сравнимы
с полномасштабной достройкой. С другой стороны, некоторые средства, упомянутые в настоящей
главе (такие как частичные типы и статические классы), больше похожи на косметический ремонт
спальни, а средства вроде псевдонимов пространств имен подобны установке датчиков дыма — вы
можете никогда и не извлечь от них пользы, но гораздо спокойнее знать, что они есть, на тот
случай, если они вдруг понадобятся.
Глава 7. Заключительные штрихи C# 2: финальные возможности 236
Часть III
C# 3: революционные изменения в доступе
к данным
Значительное улучшение C# 2 по сравнению с C# 1 не вызывает никаких сомнений. В част-
ности, обобщения являются фундаментом для других изменений, причем не только в C# 2, но
также и в C# 3. Однако в некотором смысле версия C# 2 представляет собой разрозненный набор
средств. Не поймите меня превратно: они довольно хорошо подогнаны друг к другу, но решают
набор отдельных проблем. Это было уместным на той стадии развития языка С#, но в версии C#
3 ситуация иная.
Почти каждое средство в C# 3 направлено на то, чтобы сделать возможной одну конкретную
технологию: LINQ. Многие средства полезны также за рамками этого контекста, и вы определенно
не должны ограничивать себя использованием их только для написания, скажем, выражений за-
просов. С другой стороны, было бы не менее глупо не признавать полную картину происходящего
на основе тех фрагментов головоломки, которые представлены в следующих пяти главах.
Когда я впервые писал о версии C# 3 и LINQ в 2007 году, я был крайне впечатлен доволь-
но высоким академическим уровнем изменений. Чем более глубоко вы будете изучать язык, тем
более четко сможете видеть гармонию между различными элементами, которые были введены.
Элегантность выражений запросов и особенно возможность применять одинаковый синтаксис для
внутрипроцессных запросов и поставщиков, подобных LINQ to SQL, была очень привлекательной.
Язык LINQ обещал многое.
Теперь, спустя несколько лет, я могу снова возвратиться к обещаниям и посмотреть, какую
роль они сыграли. Мой собственный опыт и сообщество, в частности Stack Overflow, позволяют
утверждать, что язык LINQ совершенно очевидно был широко принят разработчиками и действи-
тельно изменил подход к решению многих задач, связанных с данными. Поставщики баз данных не
ограничиваются только теми, которые предлагает Microsoft; достаточно назвать лишь два других
доступных варианта — LINQ to NHibernate и LINQ to SubSonic. В Microsoft также не прекратили
вводить новшества в LINQ; в главе 12 будут описаны средства Parallel LINQ и Reactive Extensions,
которые представляют собой два совершенно разных способа обработки данных, предусматриваю-
щие использование знакомых операций LINQ. А сверх того есть еще LINQ to Objects — простейший,
самый предсказуемый и обыкновенный поставщик LINQ, который наиболее распространен в этой
отрасли. Дни, когда приходилось писать очередной цикл для фильтрации, еще один фрагмент кода
для поиска максимального значения, дополнительную проверку для выяснения, удовлетворяют ли
элементы коллекции определенному условию, ушли — и скатертью им дорога,
Несмотря на широкое применение LINQ, я по-прежнему вижу ряд вопросов, свидетельствующих
о том, что некоторые разработчики воспринимают LINQ как своего рода волшебный черный ящик.
Что происходит, когда используется выражение запроса в сравнении с применением расширяю-
щих методов напрямую? Когда данные на самом деле читаются? Каким образом обеспечить более
эффективную работу? Хотя язык LINQ можно неплохо изучить, просто применяя его и прораба-
тывая готовые примеры, вы получите намного больше знаний, если ознакомитесь с тем, как все
функционирует на языковом уровне, а также с тем, что предлагают многочисленные библиотеки.
Эта книга не посвящена LINQ — внимание будет сосредоточено на языковых средствах, которые
делают LINQ возможным, а не на соглашениях о параллельном выполнении для инфраструктуры
Entity Framework и тому подобном. Но после того, как вы освоите элементы языка по отдельности
и узнаете, как они сочетаются друг с другом, вы будете гораздо лучше готовы к исследованию
деталей конкретных поставщиков.
ГЛАВА 8
В этой главе...
• Автоматически реализуемые свойства
• Неявно типизированные локальные переменные
• Инициализаторы объектов и коллекций
• Неявно типизированные массивы
• Анонимные типы
В дополнение к описанию, что каждое средство делает, будут даны рекомендации относи-
тельно его использования. Многие возможности C# 3 требуют определенной осмотрительности
и сдержанности со стороны разработчика. Это не говорит о том, что они не являются мощными
или полезными — как раз наоборот, — но искушение воспользоваться новейшим великолепным
синтаксисом не должно брать верх над стремлением писать ясный и читабельный код.
Соображения, высказываемые в этой главе (в остальных главах книги), редко будут черно-
белыми. Возможно, больше, чем когда-либо прежде, читабельность определяется личными пред-
почтениями, и по мере освоения новых средств код наверняка станет более понятным при чтении.
Однако я должен подчеркнуть, что если нет оснований полагать, что написанный вами код будет
читаться только вами, то придется принимать во внимание потребности и взгляды ваших коллег.
Итак, оставим долгие раздумывания и начнем со средства, которое не должно вызывать какие-
либо споры. Простые, но эффективные автоматически реализуемые свойства всего лишь улучшают
жизнь.
...компилируется как...
Age = age;
lock (counterLock) Использование блокировки для безопасного доступа к свойству
{
InstanceCounter++;
}
}
}
this.Value = value;
}
}
Вот и все, что необходимо для автоматически реализуемых свойств. Никакие лишние украше-
ния они не поддерживают. Например, не существует способа объявления таких свойств с началь-
ными стандартными значениями, равно как приема, позволяющего сделать их подлинно допус-
кающими только чтение (максимум, чего можно добиться — это реализовать закрытое средство
установки).
Если бы все возможности C# 3 были настолько простыми, мы могли бы раскрыть их все
в единственной главе. Конечно же, это не так, но есть средства, которые не требуют слишком
длинных объяснений. Тема, рассматриваемая следующей, посвящена устранению дублированного
кода в еще одной распространенной, хотя и специфичной ситуации — при объявлении локальных
переменных.
на такой:
Результаты действия этих двух строк кода (в терминах скомпилированного кода) в точности
совпадают, предполагая, что типом someInitialValue является МуТуре. Компилятор просто
получает тип выражения инициализации на этапе компиляции и назначает его переменной. Это
может быть любой нормальный тип .NET, в том числе обобщение, делегат либо интерфейс. Пе-
ременная по-прежнему является статически типизированной; вы просто не указываете имя типа в
коде.
2
В версии C# 4 правила игры снова меняются, разрешая использование динамической типизации там, где это
необходимо, как будет показано в главе 14. Один шаг за раз — язык C# по-прежнему был статически типизированным
до версии C# 3 включительно.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 243
Последнее очень важно понимать, поскольку оно служит главной причиной, по которой многие
разработчики остерегаются этого нового средства, считая, что ключевое слово var превращает
язык C# в динамический или слабо типизированный. Все совершенно не так. Лучше всего это
объяснить на примере следующего недопустимого кода:
int totalAge = 0;
foreach (var person in family)
{
totalAge += person.Age;
} (local variable) 'a person
Anonymous Types:
'a is new { string Name, int Age }
Рис. 8.2. Наведение курсора мыши на часть var в среде Visual Studio
приводит к отображению типа объявленной переменной
Этот случай показан на рис. 8.3, где применяется то же самое объявление, но курсор наводится
на место использования переменной. В данной ситуации поведение точно повторяет то, что можно
видеть в случае обычного объявления локальной переменной.
Среда Visual Studio привлекается в этом контексте по двум причинам. Во-первых, в большей
степени подтверждается применение статической типизации — компилятору точно известен тип
переменной. Во-вторых, демонстрируется возможность выяснения типа переменной даже глубоко
внутри кода метода. Это будет важно при обсуждении доводов за и против относительно исполь-
зования неявной типизации. Однако сначала следует упомянуть о ряде ограничений.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 244
НЕПРАВИЛЬНО
var starter = delegate() { Console.WriteLine(); };
Причина в том, что компилятор не может выяснить, какой тип использовать. Однако можно
записать следующим образом:
var starter = (ThreadStart) delegate() { Console.WriteLine(); };
Но если вы собираетесь поступать так, то лучше с самого начала объявить переменную явно. То
же самое верно в случае с null — можно было бы выполнить приведение null соответствующим
образом, но в этом нет никакого смысла.
Обратите внимание, что в качестве выражения инициализации допускается применять ре-
зультаты вызова методов или свойства — вы не ограничены использованием только констант и
обращений к конструкторам. Например, можно было бы написать следующий код:
var args = Environment.GetCommandLineArgs();
3
Термин анонимная функция охватывает анонимные методы и лямбда-выражения, которые будут рассматриваться
в главе 9.
4
Это крайне необычно в любом случае, но возможно в нормальных объявлениях, если приложить достаточно
усилий.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 245
Каковы тогда аргументы против неявной типизации? Как ни парадоксально, самым важным
аргументом против является читабельность, и это притом что она также считается аргументом за
неявную типизацию! Не указывая явно тип для объявляемой переменной, вы можете затруднить
выяснение ее предназначения при чтении кода. Это нарушает образ мышления в стиле “заявить,
что объявляется, а затем указать начальное значение”, который сохраняет объявление и инициа-
лизацию отдельными. Насколько это окажется проблемой, зависит как от читателя кода, так и от
задействованного выражения инициализации.
Если явно вызывать конструктор, всегда будет совершенно ясно, что именно создается. Ес-
ли вызывается метод или применяется свойство, то все зависит от того, насколько очевидным
является возвращаемый тип при простом взгляде на данный вызов. Целочисленные литералы —
это пример, когда высказать предположение о типе выражения труднее, чем может показаться.
Насколько быстро вы сможете выяснить типы объявленных ниже переменных?
var а = 2147483647;
var b = 2147483648;
var с = 4294967295;
var d = 4294967296;
var е = 9223372036854775807;
var f = 9223372036854775808;
Ответом будет int, uint, uint, long, long и ulong, соответственно — используемый тип
зависит от значения выражения. Здесь нет ничего нового с точки зрения обработки литералов, т.к.
компилятор C# всегда ведет себя подобным образом, но неявная типизация в этом случае только
способствует получению непонятного кода.
Аргумент, который редко явно высказывается, но, я уверен, стоит за большинством вопросов,
связанных с неявной типизацией, звучит так: “Ощущение, что что-то здесь не так”. Если вы го-
дами имели дело с языком, подобным С, то в неявной типизации есть нечто такое, что вызывает
беспокойство, хотя было бы достаточно сказать себе, что “за кулисами” по-прежнему применяется
статическая типизация. Такое беспокойство нельзя считать разумным, но это не делает его менее
реальным. Если вы испытываете неудобства, то, скорее всего, будете менее продуктивно рабо-
тать. Вполне нормально, даже если лично для вас преимущества не перевешивают негативные
ощущения. В зависимости от особенностей характера вы можете попробовать подтолкнуть себя в
направлении более спокойного отношения к неявной типизации, но определенно не обязаны делать
это.
8.2.4 Рекомендации
Ниже приведены некоторые рекомендации, собранные на основе моего опыта работы с неявной
типизацией. Все это только рекомендации, так что относитесь к ним с изрядной долей скепсиса.
• Если важно, чтобы читатель кода определял тип переменной с первого же взгляда, исполь-
зуйте явную типизацию.
• Если точный тип переменной не важен, но общая природа ясна из контекста, используйте
неявную типизацию, чтобы убрать акцент с того, каким образом код достигает цели, и
сосредоточить внимание на более высоком уровне, связанном с тем, что именно он достигает.
• При наличии сомнений попробуйте написать строку кода обоими способами и посмотрите,
какой вариант вам больше нравится.
Ранее я применял явную типизацию в производственном коде всегда кроме ситуаций, в кото-
рых использование неявной типизации сулило очевидную и значительную выгоду. Большинство
случаев применения неявной типизации касалось тестового кода (а также разового кода). В наши
дни я веду себя более неоднозначно и откровенно непоследовательно. Я благополучно исполь-
зую неявную типизацию в производственном коде лишь ради небольшого упрощения, даже когда
набирать задействованные имена типов не особо обременительно. Хотя последовательность в опре-
деленных аспектах стиля кодирования довольно-таки важна, я не обнаружил, чтобы такой подход
со смешиванием и подгонкой вызывал какие-то проблемы.
В сущности, мои рекомендации сводятся к отказу от применения неявной типизации лишь
потому, что она экономит несколько нажатий клавиш. Там, где неявная типизация позволяет по-
лучить более аккуратный код, давая возможность сосредоточиться на наиболее важных элементах
кода, ее имеет смысл использовать. Я буду широко применять неявную типизацию в остальных ма-
териалах книги по той простой причине, что код труднее форматировать под требования печатной
страницы, чем на экране монитора — доступна не такая уж большая ширина строки.
Мы снова возвратимся к неявной типизации, когда начнем рассматривать анонимные типы,
т.к. они создают ситуации, в которых приходится просить компилятор вывести типы для набора
переменных. Но сначала давайте посмотрим, каким образом C# 3 облегчает конструирование и
наполнение нового объекта в одном выражении.
Листинг 8.2. Довольно простой класс Person, предназначенный для будущих демонстраций
Код в листинге 8.2 прямолинеен, но уместно отметить, что при создании объекта, представляю-
щего человека, список друзей и местоположение дома создаются пустыми, а не остаются ссылками
null. Вдобавок свойства, хранящие список друзей и местоположение дома, допускают только чте-
ние. Это будет важно позже, а пока давайте посмотрим на свойства, которые представляют имя и
возраст человека.
tom1.Age = 9 ;
Person tom2 = new Person("Tom");
tom2.Age = 9 ;
Часть в фигурных скобках в конце каждой строки — это инициализатор объекта. Опять-таки,
он является трюком компилятора. Код IL, применяемый для инициализации tom3 и tom4, иденти-
чен и очень близок к коду, используемому для инициализации tom16 . Как и можно было ожидать,
код для tom5 практически совпадает с кодом для tom2. Обратите внимание на отсутствие круг-
лых скобок для конструктора в инициализации tom4. Такое сокращение можно применять для
типов с конструктором без параметров, который и будет вызываться в скомпилированном коде.
После вызова конструктора указанные свойства устанавливаются очевидным образом. Они
устанавливаются в порядке, в котором заданы внутри инициализатора объекта, и каждое кон-
кретное свойство можно указывать только один раз — например, не допускается устанавливать
свойство Name дважды. (Можно было бы вызвать конструктор, принимающий имя в качестве
параметра, а затем установить свойство Name. Хотя это бессмысленно, но компилятор не бу-
дет препятствовать подобным действиям.) Выражение, применяемое для значения свойства, может
быть любым выражением, которое само не является присваиванием — можно вызывать методы, со-
здавать новые объекты (потенциально используя другой инициализатор объекта) и делать многое
другое.
Вас может интересовать, насколько это полезно — сэкономлена одна или две строки кода, но,
несомненно, это не повод делать язык более сложным, не так ли? Тем не менее, здесь присутствует
один тонкий момент: вы не просто создали объект в одной строке — вы создали его в одном
выражении. Эта разница может быть очень важна.
Предположим, что требуется создать массив типа Person[] с некоторыми предопределенными
данными. Даже без применения неявной типизации массивов, которая будет показана позже, код
получается лаконичным и читабельным:
В простом примере вроде этого можно было бы написать конструктор, принимающий в качестве
параметров имя и возраст, и инициализировать массив способом, похожим на то, как это делалось
в C# 1 или C# 2. Однако подходящие конструкторы не всегда доступны, а при наличии множества
параметров часто неясен смысл того или иного параметра, а понятна только его позиция в списке.
6
На самом деле новое значение tom1 не присваивается до тех пор, пока не будут установлены все свойства. А до
этого момента используется временная локальная переменная. Этот факт редко оказывается важным, но о нем полезно
знать, чтобы избежать путаницы, если вдруг отладчик остановится на середине пути выполнения инициализатора.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 250
К тому времени, когда конструктор должен принимать пять-шесть параметров, я часто замечаю,
что полагаюсь на средство IntelliSense больше, чем того хотел бы. В таких случаях большим
благом для читабельности становится использование имен свойств7 .
Пожалуй, эта форма инициализатора объекта является одной из наиболее часто применяемых.
Тем не менее, доступны еще две формы, одна из которых предназначена для установки подсвойств,
а другая — для добавления элементов в коллекции. Давайте сначала посмотрим, что собой пред-
ставляют подсвойства — свойства свойств.
Как и с почти всеми средствами С#, инициализаторы объектов не зависят от пробельных симво-
лов. При желании вы можете устранить пробельные символы в инициализаторе объекта, поместив
весь код в одну строку. Только от вас зависит, каким образом балансировать между длинными
строками и большим количеством строк.
7
В версии C# 4 предлагается альтернативный подход, предусматривающий применение именованных аргументов,
который будет рассматриваться в главе 13.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 251
Мы имели дело со свойством Home, но как насчет друзей человека по имени Том? В типе
List<Person> есть свойства, которые можно устанавливать, но ни одно из них не будет до-
бавлять элементы в список. Самое время переходить к следующему средству — инициализаторам
коллекций.
В точности как с инициализаторами объектов, при желании можно указывать аргументы либо
применять конструктор без параметров, явно или неявно. Использование здесь неявной типизации
частично объясняется необходимостью в сокращении кода — переменная names могла бы с тем
же успехом быть объявлена и явно. Сокращение количества строк кода (без снижения читабель-
ности) — это хорошо, но с инициализаторами коллекций связаны два больших преимущества.
Первый аспект становится важным, когда коллекцию нужно передавать в виде аргумента ме-
тоду или применять в качестве одного элемента в более крупной коллекции. Это случается от-
носительно редко (хотя достаточно часто для того, чтобы быть полезным). Второй аспект, по
моему мнению, является настоящей причиной пользоваться инициализаторами коллекций. Глядя
на код справа, легко увидеть необходимую информацию, при этом каждая порция информации за-
писывается только один раз. Один раз встречается имя переменной, один раз применяется тип, и
каждый элемент инициализируемой коллекции появляется лишь однократно. Код исключительно
прост и намного яснее, чем код C# 2, который содержит помимо важной много малозначительной
информации.
Инициализаторы коллекций не ограничиваются только списками. Их можно использовать с
любым типом, который реализует интерфейс IEnumerable, при условии, что он имеет подходя-
щий метод Add() для каждого элемента в инициализаторе. Метод Add() можно применять с
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 252
более чем одним параметром, помещая значения внутрь другого набора фигурных скобок. Самое
распространенное использование такого приема связано с созданием словарей. Например, если
требуется словарь, отображающий имена на возрасты, можно было бы написать следующий код:
В данном случае метод Add(string, int) будет вызван три раза. Если доступно несколько
методов Add(), разные элементы инициализатора могут обращаться к разным перегруженным
версиям. Если для указанного элемента совместимая перегруженная версия отсутствует, код не
скомпилируется. В этом проектном решении присутствуют два интересных аспекта.
• Тот факт, что тип должен реализовывать интерфейс IEnumerable, никогда не используется
компилятором.
• Метод Add() находится только по имени — в интерфейсе нет требований о его указании.
{
Name = "Tom", Установка свойств напрямую
Age = 9,
Home = { Town = "Reading", Country = "UK" }, Инициализация встроенного объекта
Friends =
{
new Person { Name = "Alberto" }, Инициализация коллекции с помощью
добавочных инициализаторов объектов
new Person("Max"),
new Person { Name = "Zak", Age = 7 },
new Person("Ben"),
new Person("Alice")
{
Аgе = 9,
Home = { Town = "Twyford", Country = "UK" }
}
}
};
Код в листинге 8.3 пользуется всеми описанными ранее возможностями инициализаторов объ-
екта и коллекции. Главный интерес представляет инициализатор коллекции, который сам внут-
ренне использует много различных форм инициализаторов коллекций. Обратите внимание на то,
что здесь не создается новая коллекция, а производится добавление элементов в существующую
коллекцию. (Если свойство располагает средством установки, оно могло бы создавать новую кол-
лекцию и по-прежнему пользоваться синтаксисом инициализаторов коллекций.)
Можно было бы продолжить дальше, указывая друзей друзей, друзей друзей друзей и т.д. Од-
нако невозможно указать, что человек по имени Том является другом человека по имени Альберто
(Alberto). В то время как объект все еще инициализируется, доступ к нему отсутствует, поэто-
му выразить циклические отношения не удастся. Это может вызвать затруднения в некоторых
случаях, но обычно проблемой не является.
Инициализаторы коллекций внутри инициализаторов объектов работают как своего рода ги-
брид автономных инициализаторов коллекций и установки свойств встроенных объектов. Для
каждого элемента в инициализаторе коллекции производится обращение к средству получения
свойства коллекции (в этом случае Friends), после чего на возвращаемом значении вызывает-
ся метод Add(). Перед добавлением элементов коллекция никак не очищается. Например, если
вы решили, что человек должен всегда быть другом самому себе, и добавили в список друзей
this внутри конструктора Person, то во время применения инициализатора коллекции должны
добавляться только дополнительные друзья.
Как видите, сочетание инициализаторов коллекций и объектов можно использовать для напол-
нения целого дерева объектов. Но когда и где это реально происходит?
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 254
Константные коллекции
У меня не так уж редко возникает потребность в коллекции (часто в виде карты), кото-
рая фактически является константной. Разумеется, она не может быть константной в смысле
языка С#, но ее можно объявить статической и доступной только для чтения, и это поз-
волит с большой уверенностью говорить, что изменяться она не должна. (Обычно коллекция
является закрытой, что довольно-таки хорошо. В качестве альтернативы можно применять тип
ReadOnlyCollection<T>.) Как правило, при этом приходится писать статический конструктор
или вспомогательный метод, предназначенный для наполнения карты. Благодаря инициализаторам
коллекций C# 3, все легко установить в коде.
При написании модульных тестов мне нередко нужно заполнить объект всего лишь для од-
ного теста, часто передавая его в виде аргумента методу, который тестируется в данный момент.
Полный код инициализации может быть многословным и также скрывать внутреннюю структуру
объекта от читателя кода, подобно тому, как код создания XML-разметки часто мешает понять
внешний вид документа во время просмотра этого кода (соответственно сформатированного) в
текстовом редакторе. С помощью подходящих отступов для инициализаторов объектов вложенная
структура иерархии объектов может стать более очевидной в самом коде, а также сделать значе-
ния более заметными, чем они были бы в противном случае.
Шаблон построителя
По разным причинам иногда для одиночного вызова метода или конструктора требуется ука-
зать множество значений. Самая распространенная ситуация в моей практике связана с созданием
неизменяемого объекта. Вместо того чтобы иметь большой набор параметров (которые могут при-
вести к проблеме с читабельностью, поскольку предназначение каждого аргумента становится
неясным8 ), можно воспользоваться шаблоном построителя (Builder) — создать изменяемый тип
с подходящими свойствами и затем передавать экземпляр этого построителя в конструктор или
метод. Хорошим примером может служить тип ProcessStartInfo в инфраструктуре — про-
ектировщики могли бы предусмотреть перегруженные версии для метода Process.Start() с
множеством разных наборов параметров, но применение ProcessStartInfo делает все проще.
Инициализаторы объектов и коллекций позволяют создавать объект построителя в более ясной
манере — при желании его можно даже указать внутри обращения к исходному члену. Общеиз-
вестно, что вы по-прежнему должны сначала определить тип построителя, но в этом помогают
автоматические свойства.
Конечно, в рядовом коде кроме указанных трех встречаются и другие случаи использования
новых средств, и я не собираюсь отговаривать вас применять их в тех или иных ситуациях. По-
8
Надо сказать, что именованные аргументы C# 4 оказывают помощь в этой области.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 255
Тем не менее, это не работает в отношении параметров — предположим, что нужно вызвать
метод MyMethod(), объявленный как void MyMethod(string[] names). Показанный ниже
код не скомпилируется:
Вместо этого придется сообщить компилятору тип массива, предназначенного для инициали-
зации:
Очевидно, что компилятор должен выяснить тип используемого массива. Он начинает с фор-
мирования набора всех типов выражений, известных на этапе компиляции, которые находятся
внутри фигурных скобок. Если в этом наборе оказывается в точности один тип, в который могут
быть неявно преобразованы все остальные типы, то он и будет типом массива. В противном слу-
чае (или если все значения являются выражениями без типов, такими как константные значения
null или анонимные методы, не имеющие приведений) код не скомпилируется.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 256
Обратите внимание, что только в качестве кандидатов на тип массива в целом учитываются
только типы выражений. Это означает, что иногда может понадобиться явно приводить значение
к менее специфичному типу. Например, следующий код не скомпилируется:
На основе кода в листинге 8.4 можно сказать, что синтаксис для инициализации аноним-
ного типа подобен синтаксису инициализаторов объектов, который демонстрировался в разделе
8.3.2 — только отсутствует имя типа между ключевым словом new и открывающей фигурной
скобкой. Здесь используются неявно типизированные локальные переменные, т.к. это все, что
можно применять (конечно, за исключением типа object) — имя типа, с которым должна объ-
являться переменная, отсутствует. Как видно в последней строке кода, тип имеет свойства Name
и Аgе, которые можно читать и которые получают значения, указанные в инициализаторе ано-
нимного объекта, используемом для создания экземпляра, поэтому в данном случае на консоль
выводится строка Jon is 36 years old. Свойства имеют те же типы, что и выражения в
инициализаторах — string для Name и int для Аgе. Подобно обычным инициализаторам объ-
ектов, выражения, применяемые в инициализаторах анонимных объектов, мот вызывать методы
или конструкторы, извлекать значения свойств, выполнять вычисления — в общем, делать все,
что необходимо. Возможно, вы уже начали понимать, почему неявно типизированные массивы на-
столько важны. Предположим, что требуется создать массив, содержащий целую семью, а затем
пройти по нему с целью вычисления суммарного возраста членов9 .
В листинге 8.5 именно это и делается, а также одновременно демонстрируется ряд других
интересных возможностей анонимных типов.
9
Если вы уже знакомы с LINQ, то можете счесть такой способ суммирования возрастов несколько странным.
Согласен, вызов family.Sum(p => p.Age) был бы намного лаконичнее, но давайте двигаться постепенно.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 258
Сложив вместе код в листинге 8.5 и сведения о неявно типизированных массивах из раздела
8.4, можно сделать важный вывод: все элементы, представляющие людей, в массиве familу
имеют одинаковый тип. Если бы каждый случай применения инициализатора анонимного объ-
екта Ë ссылался на отличающийся тип, компилятору не удалось бы вывести подходящий тип
для массива Ê. Внутри любой отдельно взятой сборки компилятор трактует два инициализатора
анонимных объектов как относящиеся к тому же самому типу, если у них совпадает количество
свойств, их имена и порядок следования. Другими словами, если поменять местами свойства Name
и Аgе в одном из инициализаторов, будут вовлечены два типа; подобным же образом, если ука-
зать дополнительное свойство в одной из строк или воспользоваться типом long вместо int для
возраста одного из объектов, будет введен еще один анонимный тип. В этот момент выведение
типа для массива потерпит неудачу.
Если вы когда-либо просмотрите код IL (или декомпилированный код С#) для анонимного типа, ко-
торый сгенерирован компилятором Microsoft, то увидите, что хотя два инициализатора анонимных
объектов с одинаковыми именами свойств, следующими в том же самом порядке, но имеющими
отличающиеся типы, приводят к созданию двух разных типов, на самом деле они генерируются
из одного обобщенного типа. Этот обобщенный тип параметризирован, но закрытые сконструиро-
ванные типы будут отличаться из-за того, что они получают разные аргументы типов, указанные в
разных инициализаторах.
int totalAge = 0;
foreach (var person in family)
{
totalAge += person.Age;
}
(local variable) 'a person
Anonymous Types:
'a is new { string Name, int Age}
Рис. 8.4. Наведение курсора мыши на имя переменной, которая объявлена (неявно) как имеющая
анонимный тип, приводит к отображению подробных сведений об этом анонимном типе
Теперь, когда вы увидели анонимные типы в работе, давайте посмотрим, что фактически делает
компилятор.
Глава 8. Отбрасывание мелочей с помощью интеллектуального компилятора 259
Анонимные типы также доступны в Visual Basic 9 и последующих версиях. Однако по умолчанию
их свойства изменяемые; если какое-то свойство должно быть неизменяемым, его понадобится
объявить с модификатором Key. В вычислении хеш-значений и сравнениях на предмет эквива-
лентности участвуют только свойства, определенные как ключи (т.е. с модификатором Key). При
преобразовании кода из одного языка в другой данный факт довольно легко упустить из виду.
Код работает, и для только одного свойства синтаксис установки имени (часть, выделенная
полужирным) не так уж нескладен, но если бы пришлось копировать несколько свойств, код стал
бы утомительным.
В C# 3 предлагается сокращение: если вы не укажете имя свойства, а только выражение для
вычисления значения, то последняя часть выражения будет использоваться в качестве имени при
условии, что это простое поле или свойство. Такая конструкция называется инициализатором
проекции. Это означает, что предыдущий код можно переписать следующим образом:
Вы обнаружите, что все части инициализатора анонимного объекта довольно часто будут ини-
циализаторами проекций. Как правило, подобное происходит, когда некоторые свойства берутся из
одного объекта, а некоторые — из другого, зачастую в виде части операции соединения. В любом
случае, я забегаю вперед.
В листинге 8.6 показан предшествующий код в действии, в котором применяется метод List
<T>.ConvertAll() и анонимный метод.
Листинг 8.6. Трансформация из Person в имя и признак совершеннолетия
Избежание
излишнего
накопления
данных
Анонимные типы
Подгонка Избежание
инкапсуляции большого объема
данных к одной ручного
ситуации кодирования
8.6 Резюме
До чего на вид разнородный набор средств! Вы ознакомились с четырьмя средствами, которые
довольно похожи, по крайней мере, с точки зрения синтаксиса: инициализаторы объектов, иници-
ализаторы коллекций, неявно типизированные массивы и анонимные типы. Другие два средства —
автоматические свойства и неявно типизированные локальные переменные — несколько отличают-
ся. Подобным же образом большинство средств по отдельности могли быть полезными в C# 2, в то
время как затраты на изучение неявно типизированных массивов и анонимных типов окупились,
только когда в игру вступили остальные средства C# 3.
Так что же эти средства в действительности имеют общего? Все они избавляют разработчика
от утомительного кодирования. Я уверен, что вам примерно так же, как в мне, не нравится
писать тривиальные свойства или устанавливать множество свойств по одному за раз, используя
локальную переменную — особенно при попытках построить коллекцию схожих объектов. Мало
того, что новые средства C# 3 упрощают написание кода, они также облегчают его чтение, во
всяком случае, при разумном применения.
В следующей главе будет рассмотрено новое крупное языковое средство наряду с функцио-
нальной возможностью инфраструктуры, для прямой поддержки которой оно предназначено. Если
вы думаете, что анонимные методы сделали создание делегатов легким, то просто подождите
немного, пока не увидите лямбда-выражения.
ГЛАВА 9
В этой главе...
• Синтаксис лямбда-выражений
В главе 5 было показано, что версия C# 2 намного облегчает использование делегатов, благо-
даря неявным преобразованиям групп методов, анонимным методам и вариантности возвращаемого
типа и параметров. Этого вполне достаточно для значительного упрощения и улучшения читабель-
ности подписки на события, но делегаты в C# 2 по-прежнему остаются слишком громоздкими,
чтобы ими можно было пользоваться на постоянной основе. Чтение страницы кода, переполненной
анонимными методами, требует немалых усилий, к тому же вряд ли возникнет желание начать
регулярно размещать несколько анонимных методов в одиночном операторе.
Одним из фундаментальных строительных блоков LINQ является возможность создания кон-
вейеров операций наряду с любым состоянием, требуемым этими операциями. Операции могут
выражать все виды логики для обработки данных: фильтрацию, упорядочение, соединение разных
источников данных и многое другое. Когда запросы LINQ выполняются внутри одного процесса,
такие операции обычно представляются посредством делегатов.
Операторы, содержащие несколько делегатов, характерны при манипулировании данными с по-
мощью LINQ to Objects1 , и лямбда-выражения в C# 3 делают все это возможным, не принося в
жертву читабельность.
1
В LINQ to Objects последовательности данных обрабатываются внутри того же самого процесса. В противопо-
ложность этому, поставщики вроде LINQ to SQL выгружают такую работу во внешние по отношению к процессу
системы — например, базы данных.
Глава 9. Лямбда-выражения и деревья выражений 264
Китайская грамота
Выполнение делегатов — лишь часть сюжета, связанного с LINQ. Для эффективной работы
с базами данных и другими механизмами запросов необходимо другое представление операций
в конвейере — способ трактовки кода как данных, которые можно исследовать программно. За-
тем логика внутри этих операций может быть трансформирована в другую форму, такую как
обращение к веб-службе, запрос SQL или LDAP — все, что подходит в конкретной ситуации.
Несмотря на возможность построения представлений для запросов в отдельном API-интерфейсе,
как правило, усложняется чтение кода и утрачивается немалая часть поддержки со стороны ком-
пилятора. Здесь лямбда-выражения опять спасают положение помимо того, что они могут приме-
няться для создания экземпляров делегатов, компилятор C# также способен трансформировать их
в деревья выражений (структуры данных, представляющие логику лямбда-выражений), которые
могут быть проанализированы в другом коде. Словом, лямбда-выражения — это идиоматический
способ представления операций в конвейерах данных LINQ но мы будем рассматривать по одно-
му аспекту за раз, исследуя их довольно изолированно и постепенно охватывая всю технологию
LINQ.
В этой главе мы обсудим оба способа использования лямбда-выражений, хотя пока что опи-
сание деревьев выражений будет относительно элементарным, т.к. код SQL создаваться не будет.
Освоив эту теорию, вы будете достаточно хорошо знать лямбда-выражения и деревья выражений
к моменту, когда мы доберемся до действительно впечатляющих вопросов в главе 12.
Последний раздел этой главы посвящен исследованию изменений выведения типов в C# 3,
которые в основном обусловлены появлением лямбда-выражений с неявными типами параметров.
Это напоминает обучение завязыванию шнурков: сам процесс нельзя назвать захватывающим, но
без такого умения вы споткнетесь, как только начнете бежать.
Давайте приступим к выяснению, как выглядят лямбда-выражения. Мы начнем с анонимного
метода и постепенно трансформируем его во все более и более короткие формы.
TResult Func<TResult>()
TResult Func<T,TResult>(T arg)
TResult Func<T1,T2,TResult>(T1 arg1, T2 arg2)
TResult Func<T1,T2,T3,TResult>(T1 arg1, T2 arg2, T3 arg3)
TResult Func<T1,T2,T3,T4,TResult>(T1 arg1, T2 arg2, T3 arg3, T4 arg4)
3
Возможно, вы помните, что в главе 6 мы уже сталкивались с версией, вообще не принимающей параметров (но
имеющей один параметр типа).
Глава 9. Лямбда-выражения и деревья выражений 266
Func<string,int> returnLength;
returnLength = delegate (string text) { return text.Length; };
Console.WriteLine(returnLength("Hello"));
Код из листинга 9.1 выводит на консоль число 5, как и можно было ожидать. Я разделил
объявление и присваивание returnLength, чтобы уместить код делегата в одну строку — так
будет проще его отслеживать. Выражение анонимного метода выделено полужирным: это та часть,
которая будет преобразована в лямбда-выражение.
Самая длинная форма лямбда-выражения выглядит так:
(список-явно-типизированных-параметров) => операторы
Часть => представляет собой нововведение версии C# 3 и сообщает компилятору о том, что
применяется лямбда-выражение. В большинстве случаев лямбда-выражения используются с типом
делегата, который имеет возвращаемый тип, отличный от void, и когда возвращаемый результат
отсутствует, синтаксис несколько менее интуитивно понятен. Это является еще одним свидетель-
ством идиоматических изменений, произошедших в языке между версиями C# 1 и C# 3. В C# 1
делегаты обычно применялись для событий и редко что-то возвращали. В LINQ они обычно ис-
пользовались как часть конвейера данных, получая входные данные и возвращая результат, кото-
рый сообщает о том, что собой представляет спроецированное значение, соответствует ли элемент
текущему фильтру и т.д.
Благодаря явным параметрам и операторам в фигурных скобках, эта версия выглядит очень
похожей на анонимный метод. В листинге 9.2 приведен код, эквивалентный коду из листинга 9.1,
но в нем применяется лямбда-выражение.
Func<string,int> returnLength;
returnLength = (string text) => { return text.Length; };
Console.WriteLine(returnLength("Hello"));
И снова в коде выделено полужирным выражение, используемое для создания экземпляра деле-
гата. При чтении лямбда-выражений удобно воспринимать часть => как “идет в”, поэтому пример
в листинге 9.2 можно было бы прочитать как “text идет в text.Length”. Так как это единствен-
ная часть листинга, которая пока что интересует, с этого момента она будет показываться одна.
Текст, выделенный в листинге 9.2 полужирным, можно заменить любыми лямбда-выражениями,
перечисленными в этом разделе, и результат останется таким же.
Те же самые правила, которые управляют операторами return в анонимных методах, приме-
нимы и к лямбда-выражениям: нельзя возвращать значение из лямбда-выражения с возвращаемым
типом void, а когда он не void, то каждый путь в коде должен возвращать значение совмести-
мого типа4 . Все это интуитивно понятно и редко создает препятствия.
Пока что мы не сократили код особо значительно, равно как и не улучшили каким-то образом
читабельность. Давайте займемся применением сокращений.
4
Разумеется, пути в коде, генерирующие исключения, не обязаны возвращать какие-то значения, и это также
касается обнаруживаемых бесконечных циклов.
Глава 9. Лямбда-выражения и деревья выражений 267
Начинает выглядеть проще. А что теперь можно сказать о типе параметра? Компилятору
уже известно, что экземпляры Func<string, int> принимают единственный параметр типа
string, поэтому должна быть возможность указания только имени данного параметра.
Список неявно типизированных параметров — это просто список имен, разделенных запяты-
ми, не содержащий типов. Указывать типы для одних параметров и не указывать для других
не допускается — список в целом должен содержать либо явно типизированные, либо неявно
типизированные параметры.
Кроме того, при наличии параметров out или ref придется использовать явную типизацию. С
нашим примером в этом плане все в порядке, поэтому лямбда-выражение становится следующим:
Теперь оно довольно короткое. Осталось не так много, от чего можно было бы избавиться.
Хотя круглые скобки кажутся лишними.
Func<string,int> returnLength;
returnLength = text => text.Length;
Console.WriteLine(returnLength("Hello"));
На первых порах код в листинге 9.3 может сбивать с толку при чтении, подобно тому, как
анонимные методы выглядят странными для многих разработчиков до тех пор, пока они не нач-
нут пользоваться ими. В обычных обстоятельствах вы объявляете переменную и присваиваете
ей значение в одном и том же выражении, делая его еще яснее. Однако, привыкнув к лямбда-
выражениям, вы сможете оценить, насколько они лаконичны. Трудно представить себе более ко-
роткий и ясный способ создания экземпляра делегата6 .
Преобразование в лямбда-выражение
Можно было бы вдобавок изменить имя переменной text на что-то вроде х, и в LINQ это
часто удобно, но более длинные имена предоставляют читателю полезную информацию.
6
Это не значит, что сделать такое невозможно. Некоторые языки позволяют определять замыкания в виде простых
блоков кода с “магическим” именем переменной для представления общего случая с единственным параметром.
Глава 9. Лямбда-выражения и деревья выражений 269
Тело лямбда-выражения способно само содержать лямбда-выражение, и это вполне может сбивать
с толку. В качестве альтернативы параметром лямбда-выражения может быть другой делегат, что
в равной степени плохо. Оба примера относятся к функциям высшего порядка. Если вам нравится
сталкиваться с запутанными ситуациями, загляните в загружаемый исходный код, сопровождающий
эту книгу. Данный подход распространен в функциональном программировании и иногда оказы-
вается удобным. Просто он требует определенной доли настойчивости в обретении правильного
образа мыслей.
До сих пор мы имели дело только с одиночным лямбда-выражением, приводя его в различные
формы. Давайте рассмотрим несколько примеров, чтобы конкретизировать вопросы до того, как
переходить к деталям.
class Film
{
public string Name { get; set; }
public int Year { get; set; }
}
...
var films = new List<Film>
{
new Film { Name = "Jaws", Year = 1975 },
new Film { Name = "Singing in the Rain", Year = 1952 },
new Film { Name = "Some like it Hot", Year = 1959 },
new Film { Name = "The Wizard of Oz", Year = 1939 },
new Film { Name = "It's a Wonderful Life", Year = 1946 },
new Film { Name = "American Beauty", Year = 1999 },
new Film { Name = "High Fidelity", Year = 2000 },
new Film { Name = "The Usual Suspects", Year = 1995 }
};
Action<Film> print = ❶ Создание многократно используемого делегата для вывода списка на консоль
film => Console.WriteLine("Name={0}, Year={1}",
film.Name, film.Year);
films.ForEach(print); ❷ Вывод на консоль исходного описи
.ForEach(print);
films.ForEach(print);
Первая часть листинга 9.4 отвечает за подготовку данных. Именованный тип применяется в
коде только для простоты — анонимный тип в этом конкретном случае означал бы появление еще
нескольких препятствий, которые пришлось бы преодолевать.
Перед использованием построенного списка создается экземпляр делегата Ê, который будет
применяться для вывода на консоль элементов списка. Этот экземпляр делегата используется три
раза, и именно потому для его хранения была создана переменная вместо того, чтобы каждый
раз применять отдельное лямбда-выражение. Он просто выводит на консоль одиночный элемент,
но передавая экземпляр делегата методу List<T>.ForEach(), можно отобразить на консоли
целый список. Следует отметить один тонкий, однако важный момент — точка с запятой в конце
этого оператора является частью оператора присваивания, а не лямбда-выражения. Если бы то же
самое лямбда-выражение использовалось в качестве аргумента при вызове метода, то сразу после
Console.WriteLine(...) точка с запятой бы отсутствовала.
Первым на консоль выводится исходный список безо всяких модификаций Ë. Затем в списке
находятся и выводятся на консоль все фильмы, снятые до 1960 года Ì. Это делается с помощью
еще одного лямбда-выражения, которое выполняется для каждого фильма в списке — оно только
определяет, должен ли конкретный фильм быть включен в отфильтрованный список. В исходном
коде данное лямбда-выражение указано как аргумент метода, но на самом деле компилятор создает
метод, подобный показанному ниже:
private static bool SomeAutoGeneratedName(Film film)
Глава 9. Лямбда-выражения и деревья выражений 271
{
return film.Year < 1960;
}
films.FindAll(new Predicate<Film>(SomeAutoGeneratedName))
Event: Click
Sender: System.Windows.Forms.Button, Text: Click me
Arguments: System.Windows.Forms.MouseEventArgs
Button=Left
Clicks=1
X=53
Y=17
Delta=0
Location={X=53,Y=17}
Event: MouseClick
Sender: System.Windows.Forms.Button, Text: Click me
Arguments: System.Windows.Forms.MouseEventArgs
Button=Left
Clicks=1
X=53
Y=17
Delta=0
Location={X=53,Y=17}
Конечно, все это можно было бы сделать и без лямбда-выражений, но за счет применения
лямбда-выражений код получился намного более компактным.
После демонстрации преобразования лямбда-выражении в экземпляры делегатов самое время
взглянуть на деревья выражений, которые представляют лямбда-выражения в виде данных, а не
кода.
• Свойство Туре представляет тип .NET вычисляемого выражения — его можно рассматривать
как возвращаемый тип. Например, типом выражения, которое извлекает свойство Length
строки, будет int.
Запуск кода из листинга 9.6 приведет к получению вывода (2 + 3), который указывает на то,
что различные классы выражений переопределяют метод ToString() для обеспечения вывода,
воспринимаемого человеком. На рис. 9.2 изображено дерево, сгенерированное кодом.
Глава 9. Лямбда-выражения и деревья выражений 274
add
BinaryExpression
NodeType=Add
Type=System.Int32
Слева Справа
firstArg secondArg
ConstantExpression ConstantExpression
NodeType=Constant NodeType=Constant
Type=System.Int32 Type=System.Int32
Value=2 Value=3
Рис. 9.2. Графическое представление дерева выражения, созданного кодом из листинга 9.6
Полезно отметить, что концевые выражения в коде создаются первыми: выражения строятся
снизу вверх. Это обусловлено тем фактом, что выражения являются неизменяемыми — после того,
как выражение создано, оно никогда не будет изменяться, поэтому выражения можно кешировать
и многократно использовать по своему усмотрению.
Теперь, когда дерево выражения построено, наступило время его выполнить.
Expression
Expression<TDelegate>
Итак, какой смысл делать все это? В классе LambdaExpression определен метод Compile(),
который создает делегат подходящего типа; в Expression<TDelegate> имеется другой метод
с таким же именем, но он статически типизирован для возвращения делегата типа TDelegate.
Затем этот делегат может быть выполнен обычным образом, как если бы он был создан с при-
менением нормального метода или любыми другими средствами. Сказанное иллюстрируется в
листинге 9.7 на примере того же самого выражения, что и ранее.
Код в листинге 9.7 является, вероятно, одним из самых запутанных путей вывода на консоль
числа 5, какие только можно представить. В то же время он весьма выразителен. Здесь про-
граммно создаются логические блоки и представляются в виде обычных объектов, после чего у
инфраструктуры запрашивается их компиляция в действительный код, который может быть вы-
полнен. Возможно, вам никогда не придется использовать деревья выражений подобным образом
или даже вообще строить их программно, но это дает полезную справочную информацию, которая
поможет лучше понять функционирование LINQ.
Как упоминалось в начале этого раздела, деревья выражений не слишком далеко ушли от
модели CodeDOM — к примеру, инструмент Snippy компилирует и выполняет код С#, который
вводится как простой текст. Однако между CodeDOM и деревьями выражений существуют два
значительных отличия.
Во-первых, в .NET 3.5 деревья выражений обладали возможностью представления только оди-
ночных выражений. Они не были предназначены для целых классов, методов или даже просто
операторов. В .NET 4 кое-что в этом плане изменилось, и в данной версии деревья выражений
применяются для поддержки динамической типизации — теперь можно создавать блоки, при-
сваивать значения переменным и т.д. Но по сравнению с CodeDOM по-прежнему существуют
значительные ограничения.
Во-вторых, в С# деревья выражений поддерживаются прямо на уровне языка через лямбда-
выражения. Давайте посмотрим на это прямо сейчас.
Часть () => 5 в первой строке листинга 9.8 — это лямбда-выражение. Никакие приведения
не требуются, поскольку компилятор может проверить все самостоятельно. Вместо 5 можно было
бы написать 2+3, но компилятор применил бы к этому сложению оптимизацию, заменив его сум-
мой. Важный момент здесь в том, что лямбда-выражение было преобразовано в дерево выражения.
Ограничения преобразований
Давайте рассмотрим более сложный пример, чтобы увидеть, как все работает, особенно то,
что касается параметров. На этот раз будет написан предикат, который принимает две строки
и проверяет, находится ли первая строка в начале второй. Код оказывается простым, когда он
представлен в виде лямбда-выражения (листинг 9.9).
Листинг 9.9. Демонстрация более сложного дерева выражения
Console.WriteLine(compiled("First", "Second"));
Console.WriteLine(compiled("First", "Fir"));
Это дерево выражения сложнее само по себе, особенно к тому времени, как оно преобразуется
в экземпляр LambdaExpression. В листинге 9.10 показано, как его можно было бы построить в
коде.
Листинг 9.10. Построение выражения с вызовом метода в коде
Expression call =
Expression.Call(target, method, methodArgs); ❷Создание выражения
CallExpression
из частей
var lambdaParameters = new[] { target, methodArg };
var lambda = Преобразование в
❸ LambdaExpression
Expression.Lambda<Func<string, string, bool>>
(call, lambdaParameters);
var compiled = lambda.Compile();
Console.WriteLine(compiled("First", "Second"));
Console.WriteLine(compiled("First", "Fir"));
Как видите, объем кода в листинге 9.10 значительно превышает версию с лямбда-выражением
С#. Но при этом код делает более очевидным то, что в точности происходит в дереве, и показы-
вает, как привязываются параметры.
Сначала определяется все, что необходимо знать о вызове метода, который формирует тело фи-
нального выражения Ê: цель метода (строка, на которой вызывается StartsWith()); сам метод
(как MethodInfo); и список аргументов (а этом случае содержащий всего один элемент). Так по-
лучилось, что цель и аргумент нашего метода являются параметрами, передаваемыми выражению,
однако они могли быть другими типами выражений — константами, результатами других вызовов
методов, значениями свойств и т.д.
После построения вызова метода как выражения Ë нужно преобразовать его в лямбда-выраже-
ние Ì, по пути привязав параметры. В качестве информации для вызова метода повторно исполь-
зуются те же самые ранее созданные значения ParameterExpression: порядок, в котором они
были указаны при создании лямбда-выражения — это порядок, в котором они будут выбираться,
когда в конечном итоге вызывается делегат.
На рис. 9.4 окончательное дерево выражения представлено графически. По правде говоря,
хотя это по-прежнему называется деревом выражения, факт повторного использования выражений
параметров (и так должно делаться — создание нового выражения параметра с тем же самым
именем и попытка привязки параметров подобным образом привела бы к генерации исключения
во время выполнения) означает, что в строгом смысле оно действительно деревом не является.
Бегло оценив сложность диаграммы на рис. 9.4 и кода в листинге 9.10 без попытки обратиться
к деталям, можно было бы подумать, что здесь делается что-то действительно трудное, тогда как
фактически это всего лишь единственный вызов метода. Представьте, как могло бы выглядеть
дерево выражения для по-настоящему сложного выражения — и затем выразите благодарность за
то, что версия C# 3 позволяет создавать деревья выражений из лямбда-выражений!
В качестве еще одного способа исследования той же идеи среды Visual Studio 2010 в Visual
Studio 2012 предоставляют встроенный визуализатор для деревьев выражений7 . Это может ока-
заться удобным, если вы пытаетесь найти способ построения дерева выражения в коде и хотите
получить представление о том, как оно должно выглядеть.
Просто напишите лямбда-выражение, которое делает то, что требуется, с фиктивными данны-
ми, активизируйте визуализатор внутри отладчика и затем на основе предоставленной информации
обдумайте, как построить аналогичное дерево в реальном коде. Визуализатор опирается на изме-
нения, появившиеся в .NET 4, поэтому с проектами для целевой версии .NET 3.5 он не работает.
Просто напишите лямбда-выражение, которое делает то, что требуется, с фиктивными данными,
активизируйте визуализатор внутри отладчика и затем на основе предоставленной информации
обдумайте, как построить аналогичное дерево в реальном коде. Визуализатор опирается на изме-
7
Если вы работаете с Visual Studio 2008. то можете загрузить из сети MSDN код примера для построения
похожего визуализатора (http://mng.bz/g6xd), но очевидно проще воспользоваться визуализатором, входящим в
состав Visual Studio, при наличии последующих версий.
Глава 9. Лямбда-выражения и деревья выражений 278
lambda
Expression<T>
NodeType=Lambda
Type=System.Boolean
Тело
Параметры
call
MethodCaIlExpression lambdaParameters
NodeType=Call Коллекция из
Type=System.Boolean ParameterExpression
Метод Аргументы Объект (Содержит)
methodArg
(Содержит)
ParameterExpression
NodeType=Parameter
Type=System.String
Name="y"
Рис. 9.4. Графическое представление дерева выражения, которое вызывает метод и использует
параметры из лямбда-выражения
нения, появившиеся в .NET 4, поэтому с проектами для целевой версии .NET 3.5 он не работает.
На рис. 9.5 показано диалоговое окно визуализатора для примера с методом StartsWith().
T
extVi
sua
le
E
xpr
ess
ion: (
newS
yst
em.
Li
nq.
Expr
ess
ions
.Ex
pre
ssi
on.
l
ambda
Expr
ess
ionPr
o
Val
ue:
.Lambda #Lambda1<System.Func`3
[System.String,System.String,System.Boolean]>(
System.String $x,
System.String $y) {
.Call $x.StartsWith($y)
}
Wr
ap Cl
ose He
lp
typeof. Это доступно только в IL, а не в самом С#, и та же самая операция применяется
дня создания экземпляров делегатов из групп методов.
Разобравшись со связью между деревьями выражений и лямбда-выражениями, давайте кратко
рассмотрим, по каким причинам они настолько удобны.
Этап компиляции
Этап выполнения
Поставщик LINQ to SQL
Динамический
Код делегата SQL
выполняется
прямо в CLR Выполняется в базе данных
и возвращается обратно
Результаты Результаты
запроса запроса
LINQ to Objects LINQ to SQL
Рис. 9.6. Как LINQ to Objects, так и LINQ to SQL начинают с кода C# и в конце получают результаты
запроса. Возможность выполнения кода удаленным образом появляется благодаря деревьям выражений
Исполняющая среда динамического языка (DLR) будет подробно рассматриваться в главе 14,
когда речь пойдет о динамической типизации в С#, однако деревья выражений являются основной
Глава 9. Лямбда-выражения и деревья выражений 281
частью этой архитектуры. Деревья выражений обладают тремя характеристиками, которые делают
их привлекательными для DLR.
• Они объединяемы, так что можно формировать сложное поведение на основе простых стро-
ительных блоков.
Среда DLR должна принимать решения о том, как обрабатывать разнообразные выражения, в
которых смысл мог быть тонко изменен, на основе различных правил. Деревья выражений позво-
ляют этим правилам (и результатам) быть трансформированными в код, который близок к тому,
что вы писали бы вручную, если бы знали все правила и результаты, показанные до сих пор. Это
мощная концепция, которая обеспечивает неожиданно быстрое выполнение динамического кода.
В разделе 9.3.3 я упоминал, что компилятор может выдавать ссылки на значения MethodInfo
почти так, как это делает операция typeof. К сожалению, C# не обладает такой способностью.
Это означает, что единственный способ сообщить фрагменту универсального, основанного на ре-
флексии кода о необходимости использования свойства по имени BirthDate, определенного в
заданном типе, ранее предусматривал применение строкового литерала и обеспечение того, что
при изменении имени свойства изменялся также и этот литерал. Используя версию C# 3, мож-
но построить дерево выражения, которое представляет ссылку на свойство с помощью лямбда-
выражения Затем метод может проанализировать дерево выражения, отыскать указанное свойство
и сделать с информацией все, что необходимо. Разумеется, он может также скомпилировать дерево
выражения в делегат и пользоваться им напрямую.
В качестве примера, когда такое может применяться, напишем следующий код:
Перед тем, как погружаться в темные глубины выведения типов, следует упомянуть еще об од-
ном случае применения деревьев выражений, который также связан с рефлексией. Как говорилось
Глава 9. Лямбда-выражения и деревья выражений 282
Т runningTotal = initialValue;
foreach (Т item in values)
{
runningTotal = Operator.Add(runningTotal, item);
}
Код будет функционировать даже в случаях, когда тип значений отличается от типа накап-
ливаемой суммы (runningTotal), разрешая, к примеру, добавлять целую последовательность
значений TimeSpan к DateTime. Это возможно сделать в C# 2, но оно потребует значительно
больше кропотливой работы из-за способов, по которым получается доступ к операциям через
рефлексию, особенно для элементарных типов. Деревья выражений позволяют реализации этой
“магии” быть довольно чистой, а тот факт, что они компилируются в обычный код IL, который
затем обрабатывается JIT-компилятором, обеспечивает великолепную производительность.
Были приведены только некоторые примеры, и вне всяких сомнений множество разработчиков
имеют дело с совершенно другими случаями использования деревьев выражений. Однако на этом
обсуждение непосредственно лямбда-выражений и деревьев выражений закончено. Вы еще увиди-
те их немало, когда дело дойдет до LINQ, но прежде чем двигаться дальше, осталось рассмотреть
несколько изменений языка С#, которые требуют некоторых пояснений. Эти изменения касаются
выведения типов и способа выбора компилятором перегруженных версий методов.
Листинг 9.11. Пример кода, в котором требуются новые правила выведения типов
TInput и TOutput для второго аргумента, так что код из листинга 9.11 не смог бы компилиро-
ваться.
Наша конечная цель заключается в том, чтобы понять, что обеспечит возможность успешной
компиляции кода в листинге 9.11 в C# 3, но пока начнем с чего-то более скромного.
Исправить ошибку можно двумя путями — либо указать аргумент типа явным образом (как
предлагает компилятор), либо привести анонимный метод к конкретному типу делегата:
WriteResult<int>(delegate { return 5; });
WriteResult((MyFunc<int>)delegate { return 5; });
Оба способа работают, но выглядят неуклюжими. Желательно, чтобы компилятор выполнял
такой же вид выведения типов, как в случае типов, отличных от делегатов, используя тип воз-
вращаемого выражения для выведения типа Т. Именно это в C# 3 делается для анонимных
методов и лямбда-выражений, однако есть одна загвоздка. Хотя во многих случаях задействован
только один оператор return, иногда их может быть больше. В листинге 9.13 показана слегка
измененная версия кода из листинга 9.12, в которой анонимный метол временами возвращает це-
лочисленное значение, а временами — объект.
delegate Т MyFunc<T>();
static void WriteResult<T>(MyFunc<T> function)
{
Глава 9. Лямбда-выражения и деревья выражений 285
Console.WriteLine(function());
}
...
WriteResult(delegate
{
if (DateTime.Now.Hour < 12)
{
return 10; Возвращаемым типом является int
}
else
{
return new object(); Возвращаемым типом является object
}
});
некоторых параметров, и компилятор предъявил бы претензии в случае, если для отдельного пара-
метра типа любые два аргумента приводили бы к разным результатам, даже если они совместимы.
В C# 3 аргументы могут нести в себе части информации — типы, которые должны быть неявно
преобразуемыми в окончательное фиксированное значение конкретной переменной типа. Для по-
лучения этого фиксированного значения применяется та же самая логика, что и при выведении
возвращаемых типов и в неявно типизированных массивах.
В листинге 9.14 показан пример, в котором не используются ни лямбда-выражения, ни даже
анонимные методы.
Остались ли
какие-то Нет Готово: выведение типов
нефиксированные успешно завершено
переменные
типов?
Да
Вывести дополнительную
информацию на основе
недавно фиксированных
параметров типов
Достигнут ли
какой-то
прогресс на этой
Да итерации?
Нет
Давайте рассмотрим два примера, демонстрирующие работу этого алгоритма. Первым делом
возьмем код, приведенный в начале этого раздела, в листинге 9.11:
static void PrintConvertedValue<TInput,TOutput>
(TInput input, Converter<TInput,TOutput> converter)
{
Console.WriteLine(converter(input));
}
...
PrintConvertedValue ("I'm a string", x => x.Length);
Параметрами типов, которые необходимо выяснить здесь, являются TInput и TOutput. Ниже
перечислены шаги, которые выполняются для этого.
1. Начинается этап 1.
2. Первый параметр имеет тип TInput, а первый аргумент — тип string. Мы делаем вывод,
что должно существовать неявное преобразование из string в TInput.
4. Начинается этап 2.
7. Этап 2 повторяется.
9. Теперь нефиксированных параметров типов не осталось, так что выведение успешно завер-
шено.
Сложно, не правда ли? Тем не менее, работа сделана — получен нужный вам результат
(TInput=string, TOutput=int) и все компилируется безо всяких проблем.
Важность повторения этапа 2 лучше всего подчеркнуть с помощью еще одного примера. В ли-
стинге 9.15 демонстрируется выполнение двух преобразований, причем выход первого становится
входом второго. До тех пор, пока не будет выяснен выходной тип первого преобразования, входной
тип второго преобразования не известен, поэтому вывести его выходной тип тоже невозможно.
Первое, что следует отметить — сигнатура метода выглядит довольно устрашающе. Однако
не все так плохо, если перестать бояться и взглянуть на нее внимательнее; пример применения
определенно делает ее более очевидной. Здесь берется строка и над ней выполняется преобразо-
вание — то же самое преобразование, что и ранее, т.е. просто вычисление длины. Затем для этой
длины строки (значение int) находится квадратный корень (значение double).
Этап 1 выведения типов сообщает компилятору о том, что должно существовать преобразова-
ние из string в TInput. Во время первого прохода этапа 2 тип TInput фиксируется в string и
делается вывод о необходимости наличия преобразования из int в TMiddle. На втором проходе
этапа 2 тип TMiddle фиксируется в int и делается вывод о том, что должно быть преобразование
из double в TOutput. На третьем проходе этапа 2 тип TOutput фиксируется в double и выве-
дение типов успешно завершается. По завершении выведения типов компилятор может должным
образом просматривать код внутри лямбда-выражения.
Тело лямбда-выражения не может быть проверено до тех пор, пока не станут известными типы
входных параметров. Лямбда-выражение х => х.Length допустимо, если х является массивом
или строкой, но недопустимо во многих других случаях. Это не проблема, когда типы парамет-
ров объявлены явно, но в случае списка неявно типизированных параметров компилятор должен
подождать, пока не будет выполнено подходящее выведение типов, и только потом попытаться
выяснить, в чем смысл лямбда-выражения.
void Write(int х)
void Write(double у)
Листинг 9.16. Пример выбора перегруженной версии при влиянии возвращаемого типа де-
легата
{
Console.WriteLine("action returns a double: " + action());
}
...
Execute(() => 1);
Вызов Execute() в листинге 9.16 взамен можно было бы записать с помощью анонимного
метода или группы методов — какой бы вид преобразования не был задействован, применяются
те же самые правила. Какой из методов Execute() должен быть вызван? Правила перегрузки
гласят, что когда после преобразований аргументов оба метода применимы, эти преобразования
аргументов анализируются на предмет выявления .лучшего из них. Преобразования здесь осу-
ществляются не из обычного типа .NET в тип параметра, а из лямбда-выражения в два типа
делегатов. Какое из преобразований лучше?
Как ни удивительно, но эта же ситуация в C# 2 привела бы к ошибке компиляции — язы-
ковые правила для такого случая не предусмотрены. В C# 3 будет выбран метод с параметром
Func<int>. Добавленное дополнительное правило может быть изложено своими словами следу-
ющим образом.
Если анонимная функция может быть преобразована в два типа делегатов, которые имеют
одинаковые списки параметров, но отличающиеся возвращаемые типы, то преобразования
делегатов оцениваются по преобразованиям из выведенного возвращаемого типа в
возвращаемые типы делегатов.
Без ссылки на пример звучит как порядочная тарабарщина. Давайте снова возвратимся к
листингу 9.16, в котором выполнялось преобразование из лямбда-выражения, не принимающего
параметров и имеющего выведенный возвращаемый тип int, либо в тип Func<int>, либо в
тип Func<double>. Для обоих типов делегатов списки параметров одинаковы (пустые), поэтому
применяется указанное выше правило. Затем нужно просто найти лучшее преобразование: int
в int или int в double. Ситуация должна выглядеть знакомой; как было упомянуто ранее,
преобразование int в int считается лучшим. Таким образом, код из листинга 9.16 выводит на
консоль строку action returns an int: 1.
• Выведение типов больше не требует, чтобы каждый аргумент независимо приходил к точно
такому же заключению относительно параметров типов, при условии совместимости резуль-
татов.
• Выведение типов теперь является многоэтапным: выведенный возвращаемый тип одной ано-
нимной функции может выступать в качестве типа параметра для другой такой функции.
• При поиске лучшей перегруженной версии метода, когда участвуют анонимные функции,
принимается во внимание возвращаемый тип.
Даже такой короткий список очень плотно усеян техническими терминами. Не волнуйтесь,
если вам не все в нем понятно. По моему опыту в большинстве случаев все работает так, как
нужно.
9.5 Резюме
В C# 3 лямбда-выражения почти полностью заменили анонимные методы. Анонимные методы
поддерживаются ради обратной совместимости, но идиоматический, заново написанный код C# 3
будет содержать их мало.
Вы видели, что лямбда-выражения — это нечто большее, чем просто компактный синтаксис
для создания делегатов. Они могут быть преобразованы в деревья выражении с учетом ряда огра-
ничений. Деревья выражений затем могут обрабатываться другим кодом, возможно выполняющим
эквивалентные действия в разных исполняющих средах. Без такой характеристики язык LINQ
ограничивался бы внутрипроцессными запросами.
Наше обсуждение выведения типов было в определенной степени необходимым злом; лишь
немногим разработчикам действительно нравится говорить о такой разновидности правил, кото-
рые при этом должны применяться, но важно иметь хотя бы примерное представление о том, что
происходит. Прежде чем начать чрезмерно жалеть самих себя, подумайте о бедных проектиров-
щиках языка, которым приходилось жить и дышать этим, удостоверяясь, что правила согласованы
и не разваливаются в неприятных ситуациях. Затем вспомните о тестировщиках, которые долж-
ны были пытаться нарушить работу реализации. С точки зрения описания лямбда-выражений на
этом все, но вы еще увидите много случаев их применения в остальных главах книга. Напри-
мер, в следующей главе подробно рассматриваются расширяющие методы. На первый взгляд,
они полностью отделены от лямбда-выражений, но на деле эти два средства часто используются
вместе.
ГЛАВА 10
Расширяющие методы
В этой главе...
• Написание расширяющих методов
• Вызов расширяющих методов
• Соединение методов в цепочки
• Расширяющие методы в .NET 3.5
• Другие случаи использования расширяющих методов
Я не являюсь поклонником наследования. Вернее, мне не нравятся те несколько мест, где ис-
пользовалось наследование, в сопровождаемом мною коде или библиотеках классов, с которыми
мне приходилось работать. Как и с очень многими средствами, наследование является мощным,
когда применяется должным образом, но зачастую из виду упускаются накладные расходы по про-
ектированию, которые со временем могут стать ощутимыми. Оно иногда используется в качестве
способа добавления к классу дополнительного поведения и функциональности, даже когда ника-
кой действительной информации об объекте не предоставляется и ничего не специализируется.
Иногда наследование оказывается подходящим, например, если объекты нового типа должны
заботиться о деталях дополнительного поведения, но часто это не так. В первую очередь во многих
случаях наследование просто невозможно использовать обычным образом, скажем, при работе с
типом значения, запечатанным классом либо интерфейсом. Альтернатива обычно сводится к напи-
санию набора статических методов, которые принимают экземпляр рассматриваемого типа в виде,
как минимум, одного из своих параметров. Это хорошо работает, не сопровождается проектным
неудобством в форме наследования, но может привести к получению неуклюже выглядящего кода.
В версии C# 3 появилась идея расширяющих методов, которые обладают преимуществами
решения со статическими методами и также улучшают читабельность кода, в котором они вызы-
ваются. Эта идея дает возможность обращаться к статическим методам, как если бы они были
методами экземпляра совершенно другого класса. Не паникуйте — это звучит не так глупо, как
может показаться на первый взгляд.
В данной главе мы сначала посмотрим, как пользоваться расширяющими методами и как их
писать. Затем мы проанализируем несколько расширяющих методов, предлагаемых .NET 3.5, и
Глава 10. Расширяющие методы 294
ознакомимся с тем, каким образом их легко соединять в цепочки. Эта возможность построения
цепочек является, в первую очередь, важной составляющей самой причины добавления расширя-
ющих методов в язык и важной частью LINQ1 . Наконец, мы рассмотрим некоторые за и против
применения расширяющих методов вместо обычных статических методов.
Тем не менее, давайте сначала более пристально посмотрим, почему расширяющие методы
иногда более желательны, нежели то, что было доступно в версиях C# 1 и C# 2, особенно при
создании вспомогательных классов.
Одна небольшая вариация этого заключается в том, чтобы при работе с интерфейсом вместо
класса полезное поведение добавлялось только при вызове методов данного интерфейса. Хоро-
шим примером является интерфейс IList<T>. Разве не была бы замечательной возможность
сортировки любой (изменяемой) реализации IList<T>? Было бы неприятно заставлять в каждой
реализации интерфейса принудительно реализовывать сортировку, но это неплохо с точки зрения
пользователя готового списка.
Дело в том, что IList<T> предлагает все строительные блоки для полностью обобщенной
процедуры сортировки (фактически для нескольких), но поместить эту реализацию в интерфейс
нельзя. Вместо этого IList<T> можно было бы указать как абстрактный класс, и функциональ-
ность сортировки тогда была бы включена, но поскольку C# и .NET поддерживается одиночное
наследование реализации, это наложило бы значительное ограничение на производные от него
типы. Расширяющий метод на IList<T> позволит сортировать любую реализацию IList<T>,
делая так, чтобы казалось, что эту функциональность предоставляет сам список.
Позже вы увидите, что много функциональности LINQ построено на основе расширяющих ме-
тодов поверх интерфейсов. Тем не менее, в данный момент мы будем использовать в примерах
другой тип: System.IO.Stream, краеугольный камень бинарных коммуникаций в .NET. Сам
тип Stream является абстрактным классом, имеющим несколько конкретных производных клас-
сов, таких как NetworkStream, FileStream и MemoryStream. К сожалению, есть несколько
порций функциональности, которые было бы полезно включить в Stream, но которые в нем от-
сутствуют.
Недостающие средства, которые мне чаще всего нужны, включают возможность чтения цело-
го массива в память в виде байтового массива и возможность копирования содержимого одного
1
Если вы сыты по горло утверждениями о том, что то или иное средство является “важной частью LINQ”, я вас
не осуждаю, но это все же часть его великолепия. Существует очень много небольших частей, однако их сумма дает
блестящий результат. Тот факт, что каждое средство может быть использовано и независимо — лишь дополнительная
награда.
Глава 10. Расширяющие методы 295
массива в другой2 . Оба средства часто реализуются неудачно, делая в отношении потоков пред-
положения, которые просто не являются допустимыми — самое распространенное недоразумение
заключается в том, что метод Stream.Read() будет полностью заполнять буфер, если данные
не закончились раньше.
Одно из таких средств было добавлено в .NET 4: тип Stream теперь имеет метод СоруТо().
Это удобно с точки зрения демонстрации одного довольно тонкого аспекта расширяющих методов,
и мы вернемся к нему в разделе 10.2.3. Метод ReadFully() по-прежнему отсутствует, но его
в любом случае следовало бы применять осмотрительно: поток может быть прочитан полностью
только тогда, когда точно известно, что у него есть признак конца и все данные умещаются в
памяти. Потоки не связаны обязательством иметь конечный объем данных.
Было бы неплохо иметь эту функциональность в одном месте, а не дублировать во многих про-
ектах. Именно по этой причине я создал класс StreamUtil в своей смешанной вспомогательной
библиотеке. Действительный код содержит большое количество проверок на предмет ошибок и
другую функциональность, но в листинге 10.1 представлена усеченная версия, которой более чем
достаточно для текущих потребностей.
using System.IO;
public static class StreamUtil
{
const int BufferSize = 8192;
public static void Copy(Stream input, Stream output)
{
byte[] buffer = new byte[BufferSize];
int read;
while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
{
output.Write(buffer, 0, read);
}
}
public static byte[] ReadFully(Stream input)
{
using (MemoryStream tempStream = new MemoryStream())
{
Copy(input, tempStream);
return tempStream.ToArray();
}
}
}
2
Из-за природы потоков это копирование не обязательно должно дублировать данные — они просто читаются из
одного потока и записываются в другой. Хотя копирование не является совершенно точным термином в этом смысле,
разница обычно не имеет значения.
Глава 10. Расширяющие методы 296
Детали реализации не играют особой роли, хотя полезно отметить, что в методе ReadFully()
вызывается метод Сору() — это будет полезно впоследствии для демонстрации одной особенно-
сти, связанной с расширяющими методами.
Класс StreamUtil использовать легко; например, в листинге 10.2 показано, как можно запи-
сать на диск ответ, получаемый в результате запроса веб-страницы.
Листинг 10.2. Применение класса StreamUtil для копирования в файл потока ответа, по-
лучаемого в результате запроса веб-страницы
• Первый параметр не может иметь какие-то другие модификаторы (такие как out или ref).
Это весь перечень требований; метод может быть обобщенным, возвращать значение, иметь
параметры ref/out кроме первого, быть реализованным посредством итераторного блока, быть
частью частичного класса, использовать типы, допускающие null — в общем, все, что угодно,
при условии удовлетворения указанных выше ограничений.
Мы будем называть тип первого параметра расширенным типом метода и говорить, что дан-
ный метод расширяет этот тип — в рассматриваемом случае мы расширяем тип Stream. Это не
официальная терминология из спецификации, но она является удобным сокращением.
В предшествующем списке не только описаны все ограничения, но также указаны действия,
которые необходимо предпринять для превращения обычного статического метода в расширяющий
метод — нужно просто добавить ключевое слово this. В листинге 10.3 приведен код того же
самого класса, что и в листинге 10.1, но этот раз оба метода являются расширяющими.
В листинге 10.4 вызов только выглядит так, как будто у потока ответа запрашивается вы-
полнение копирования. Всю работу “за кулисами” по-прежнему делает StreamUtil, но та-
кой код воспринимается более естественно. В сущности, компилятор преобразует обращение к
СоруТо() в вызов обычного статического метода StreamUtil.СоруТо(), передавая значение
responseStream в качестве первого аргумента (за которым следует output).
Теперь, когда вы видели код, я надеюсь, что вы понимаете причину изменения имени метода
с Сору() (“копировать”) на СоруТо() (“копировать в”). Одни имена одинаково хорошо подходят
как для статических методов, так и для методов экземпляра, но вы обнаружите, что другие имена
нуждаются в корректировке ради обеспечения максимально возможной читабельности.
Если вы хотите сделать код StreamUtil еще более симпатичным, можете изменить строку с
вызовом метода СоруТо() внутри ReadFully() следующим образом:
input.СоруТо(tempStream);
В данный момент измененное имя полностью соответствует всем случаям его применения —
хотя ничего не может воспрепятствовать использованию расширяющего метода как обычного ста-
тического метода, что может быть удобным при переносе большого объема кода.
Возможно, вы заметили, что в приведенных выше вызовах методов ничего не указывало на
применение именно расширяющего метода, а не обычного метода экземпляра Stream. Данный
факт следует рассматривать с двух сторон: с одной стороны это хорошо, если ваше намерение
заключается в том, чтобы сделать расширяющие методы как можно более гармоничными и не
причиняющими беспокойства, но с другой стороны это плохо, если нужна возможность непосред-
ственно видеть, что происходит в действительности.
Если вы работаете в Visual Studio, то можете навести курсор мыши на вызов метода и получить
внутри всплывающей подсказки указание на то, что вызывается расширяющий метод (рис. 10.1).
Средство IntelliSense также обозначает расширяющий метод, как в значке для метода, так и во
всплывающей подсказке, когда он выбран. Разумеется, вы не должны наводить курсор на каждый
вызов метода или слишком часто пользоваться IntelliSense, т.к. большую часть времени не имеет
значения, вызывается метод экземпляра или расширяющий метод.
Глава 10. Расширяющие методы 299
Рис. 10.1. Наведение курсора мыши на вызов метода в Visual Studio позволяет выяснить, является ли
метод расширяющим
В этом вызываемом коде все еще остается одна странность — в нем совершенно нигде не
упоминается класс StreamUtil! Как компилятор узнает, что должен применяться расширяющий
метод?
Если доступно сразу несколько пригодных расширяющих методов для разных расширенных
типов (с использованием неявных преобразований), то наиболее подходящий из них выбирается
посредством правил лучшего преобразования, которые задействованы при выборе перегруженной
версии метода. Например, если интерфейс IDerived унаследован от IBase, и для обоих имеется
расширяющий метод с тем же самым именем, то расширяющему методу в интерфейсе IDerived
отдается предпочтение перед таким методом в IBase. Опять-таки, это средство применяется в
LINQ, как будет показано в разделе 12.2, где вы встретитесь с интерфейсом IQueryable<T>.
Важно отметить, что если доступен пригодный метод экземпляра, он всегда будет использо-
ваться до поиска расширяющих методов, однако компилятор не выдает предупреждения, когда
расширяющий метод также соответствует существующему методу экземпляра. Например, в .NET
4 класс Stream имеет новый метод, который также называется СоруТо(). Для него определены
две перегруженных версии, одна из которых конфликтует с только что созданным расширяющим
методом. В итоге предпочтение отдается новому методу, а не расширяющему, поэтому в резуль-
тате компиляции кода из листинга 10.4 для .NET 4 будет применяться Stream.СоруТо(), а
не StreamUtil.СоруТо(). Метод класса StreamUtil по-прежнему можно вызывать статически,
используя нормальный синтаксис StreamUtil.СоруТо (input, output), но он никогда не
будет выбран как расширяющий метод. В данном случае существующему коду никакого вреда не
приносится: новый метод экземпляра имеет тот же смысл, что и ваш расширяющий метод, поэто-
му не имеет значения, какой из них применяется. В других случаях могут быть тонкие отличия в
семантике, которые иногда трудно обнаружить до нарушения работы кода.
Еще одна потенциальная проблема заключается в том, что способ, которым расширяющие
методы делаются доступными коду, чрезвычайно широкомасштабен. Если пространство имен со-
держит два класса, которые имеют методы с тем же самым расширенным типом, то нет никакого
приема, который бы позволил работать только с расширяющими методами из какого-то одного
класса. Аналогично, не существует способа импортирования пространства имен ради того, чтобы
типы стали доступными через их простые имена, не делая одновременно доступными также и
расширяющие методы, определенные внутри этого пространства имен. Чтобы смягчить эту про-
блему, можно применять пространство имен, которое содержит исключительно статические классы
с расширяющими методами, если только не окажется, что остальная функциональность этого про-
странства имен уже сильно зависит от расширяющих методов (как в ситуации с System.Linq,
например).
Один аспект расширяющих методов может довольно-таки удивить, когда вы впервые столкне-
тесь с ним, однако он также удобен в некоторых ситуациях. Речь идет о ссылках null — давайте
взглянем на них.
using System;
public static class NullUtil
{
public static bool IsNull(this object x)
{
return x == null;
}
}
public class Test
{
static void Main()
{
object у = null;
Console.WriteLine(y.IsNull());
у = new object();
Console.WriteLine(y.IsNull());
}
}
Код из листинга 10.5 выведет на консоль True и затем False. Если бы IsNull() был обыч-
ным методом экземпляра, то во второй строке Main() сгенерировалось бы исключение; взамен
IsNull() вызывается с передачей null в качестве аргумента. До появления расширяющих ме-
тодов в C# отсутствовали безопасные способы написания кода в форме, которая была бы более
читабельной, чем у.IsNull(); вместо нее требовалось использовать NullUtil.IsNull(у).
В рамках инфраструктуры имеется один особенно очевидный пример, где этот аспект пове-
дения расширяющих методов мог бы оказаться полезным: string.IsNullOrEmpty(). В C# 3
разрешено определять расширяющий метод, который имеет такую же сигнатуру (кроме наличия
дополнительного параметра для расширенного типа), как и существующий статический метод в
расширенном типе. Чтобы не заставлять вас читать эту фразу много раз, ниже приведен при-
мер — даже хотя класс string имеет статический метод без параметров IsNullOrEmpty(),
по-прежнему можно создавать и пользоваться следующим расширяющим методом:
имя для расширяющего метода — предыдущий расширяющий метод может сбить с толку читате-
лей кода, которые знакомы только со статическим методом из инфраструктуры.
По мере чтения этой главы полезно заглядывать в недавно выполненные проекты; подумайте,
можно ли было сделать их код проще или читабельнее за счет использования описываемых здесь
операций.
В классе Enumerable есть несколько методов, не являющихся расширяемыми, и мы будем
применять один из них в примерах, приводимых далее в этой главе. Метод Range() принимает
два параметра типа int: начальное число и количество выдаваемых результатов. Результатом
будет экземпляр IEnumerable<int>, возвращающий по одному числу за раз в очевидной манере.
Для демонстрации функционирования метода Range() и создания рабочей инфраструктуры
давайте выведем на консоль числа от 0 до 9, как показано в листинге 10.6.
Отложенное выполнение
Пожалуй, самое простое, что можно сделать с последовательностью чисел, которая уже упо-
рядочена — это изменить порядок на обратный. В листинге 10.7 для этого применяется расширя-
ющий метод Reverse() — он возвращает экземпляр IEnumerable<Т>, выдающий те же самые
элементы, что и исходная последовательность, но в обратном порядке.
Существуют два очевидных подхода к написанию первой части листинга 10.8, не учитыва-
ющие тог факт, что Reverse() и Where() являются расширяющими методами. Один из них
предусматривает применение временной переменной, которая сохраняет коллекцию незатронутой:
var collection = Enumerable.Range(0, 10);
collection = Enumerable.Where(collection, x => x % 2 != 0)
collection = Enumerable.Reverse(collection);
Глава 10. Расширяющие методы 306
Надеюсь, вы согласитесь с тем, что смысл этого кода намного менее очевиден, чем смысл кода
в листинге 10.8.
Второй подход, при котором все записывается в стиле единственного оператора, еще более
ухудшает положение:
Порядок вызова методов кажется обратным, поскольку первым будет выполнен самый внут-
ренний вызов (Range()), а затем остальные, и выполнение в таком случае направлено изнутри
наружу. Даже всего лишь с тремя вызовами методов код выглядит неуклюже, но с ростом коли-
чества операций все становится намного хуже.
Прежде чем двигаться дальше, давайте подумаем о том, что делает метод Where().
Можно также изменить последнюю часть листинга 6.9, сделав ее больше похожей на стиль
LINQ:
sqrt(9)=3
sqrt(7)=2.64575131106459
Глава 10. Расширяющие методы 308
sqrt(5)=2.23606797749979
sqrt(3)=1.73205080756888
sqrt(1)=1
Разумеется, метод Select() не обязан использовать анонимный тип вообще — можно было
бы выбрать только квадратный корень числа, отбросив исходное число. В этом случае результатом
был бы экземпляр IEnumerable<double>. В качестве альтернативы можно было бы вручную
написать тип, инкапсулирующий целое число и квадратный корень — просто в приведенном случае
легче было воспользоваться анонимным типом.
Давайте рассмотрим еще один метод, чтобы оставив на время обзор класса Enumerable:
OrderBy().
Обратите внимание, что кроме вызова Enumerable.Range() код читается почти как тексто-
вое описание (на английском языке). На этот раз реализация метода ToString() анонимного
типа осуществляет форматирование, так что результат выглядит следующим образом:
{ Original = 0, Square = 0 }
{ Original = -1, Square = 1 }
{ Original = 1, Square = 1 }
{ Original = -2, Square = 4 }
{ Original = 2, Square = 4 }
{ Original = -3, Square = 9 }
{ Original = 3, Square = 9 }
{ Original = -4, Square = 16 }
Глава 10. Расширяющие методы 309
{ Original = 4, Square = 16 }
{ Original = -5, Square = 25 }
{ Original = 5, Square = 25 }
Как и предполагалось, главным свойством, по которому производится сортировка, является
Square, но когда два значения дают одинаковый результат при возведении в квадрат, после
сортировки отрицательное значение всегда будет находиться перед положительным. Написание
одиночного сравнения, которое делает то же самое (в общем случае — существуют математические
трюки, позволяющие справиться с этим конкретным примером), оказалось бы настолько сложнее,
что вы отказались бы от помещения кода внутрь лямбда-выражения.
Следует отметить один момент — упорядочение не изменяет существующую коллекцию, а
возвращает новую последовательность, которая выдает те же самые данные, что и исходная по-
следовательность, но только отсортированные. Сравните это с методами List<T>.Sort() или
Array.Sort(), которые оба изменяют порядок следования элементов внутри списка или массива.
Операции LINQ спроектированы как свободные от побочных эффектов: они не оказывают
влияния на свои входные данные и не привносят любые другие изменения в среду, если только
не производится проход по естественно поддерживающей состояние последовательности (вроде
чтения из сетевого потока) или аргумент делегата не имеет побочных эффектов. Этот подход взят
из функционального программирования, и он приводит к получению кода, который является более
читабельным, тестируемым, компонуемым, предсказуемым, безопасным в отношении потоков и
надежным в работе.
Мы рассмотрели лишь несколько из множества расширяющих методов, доступных в классе
Enumerable, но есть надежда, что вы смогли по достоинству оценить, насколько аккуратно они
могут соединяться в цепочки. В следующей главе вы увидите, как это может быть выражено
по-другому с применением дополнительного синтаксиса, предоставляемого C# 3 (выражения за-
просов), и будут показаны другие операции, которые здесь не рассматривались. Стоит запомнить,
что вы не обязаны использовать выражения запросов — часто проще сделать пару обращений к
методам Enumerable и с помощью расширяющих методов соединить операции в цепочку.
Теперь, когда вы ознакомились с примером, в котором участвовала коллекция чисел, наступило
время исполнить обещание, касающееся демонстрации нескольких реальных бизнес-примеров.
упорядочивая их, трансформируя элементы, выполняя агрегирование некоторых значений или при-
меняя другие варианты. Во многих случаях результирующий код можно читать вслух и сразу же
понимать, а в других ситуациях он по-прежнему намного проще эквивалентного кода, который
пришлось бы писать в предшествующих версиях С#.
Пример данных по отслеживанию дефектов будет использоваться в следующей главе при рас-
смотрении выражений запросов. Теперь, после ознакомления с некоторыми расширяющими мето-
дами, давайте подумаем, когда имеет смысл создавать такие методы самостоятельно.
Для заданной задачи типичным является то, что программист приучен строить решение до
тех пор, пока оно, наконец, не начнет удовлетворять требованиям. Теперь стало
возможным расширить мир с целью удовлетворения требованиям решения вместо одного
лишь построения для удовлетворения требований мира. Если данная библиотека не
предоставляет того, что нужно, просто расширьте ее, чтобы она удовлетворяю
существующим требованиям.
Возвращает SoloMeeting
Meeting.Between("Jon")
.And("Russell") Возвращает UntimedMeeting
.At(8.OClock().Tomorrow()) Возвращает Meeting
Возвращает TimeSpan
Возвращает DateTime
Грамматически пример на рис. 10.2 мог бы принимать множество разных форм; скажем, есть
возможность добавить к экземпляру UntimedMeeting дополнительные объекты участников или
создать экземпляр UnattendedMeeting в определенное время до указания участников. За более
подробными сведениями о языках DSL обращайтесь к книге Орена Эйни (псевдоним Ayende
Rahien) DSLs in Boo: Domain-Specific Languages in NET (Manning, 2010 г.).
В C# 3 поддерживаются только расширяющие методы, но не расширяющие свойства, что
слегка ограничивает текучие интерфейсы. Это означает невозможность иметь выражения, по-
добные 1.week.from.now или 2.days + 10.hours (которые оба являются допустимыми в
Groovy с подходящим пакетом: ttp://groovy.codehaus.org/Google+Data+Supportttp:
Глава 10. Расширяющие методы 313
• Хорошо подумайте, прежде чем расширять повсеместно применяемые типы, такие как число-
вые типы или object, либо писать метод, в котором расширенный тип является параметром
типа. В ряде руководств даже рекомендуется никогда не поступать подобным образом; я
считаю, что такие расширения имеют право на существование, но они должны действи-
тельно заслужить свое место в вашей библиотеке. В этой ситуации становится даже еще
более важным то, чтобы расширяющий метод был внутренним или находился в собственном
пространстве имен. К примеру, я не хотел бы, чтобы средство IntelliSense предлагало мне
расширяющий метод June() везде, где я использую целое число, а только в классах, в
которых применяются хоть какие-то расширяющие методы, связанные с датой и временем.
• Решение о написании расширяющего метода всегда должно быть осознанным. Оно не должно
превращаться в привычку. Не каждый статический метод заслуживает того, чтобы стать
расширяющим методом.
• Будьте осторожны, чтобы не использовать имя метода, которое уже имеет определенный
смысл в расширенном тине. Если расширенный тип является типом из инфраструктуры или
поступает из независимой библиотеки, проверяйте имена всех своих расширяющих мето-
дов всякий раз, когда меняете версии библиотеки. Если вам повезет (как мне с методом
Stream.СоруТо()), то новый смысл сохранится таким же, как ранее, но даже при этих
условиях может понадобиться объявить расширяющий метод устаревшим.
10.5 Резюме
Технический аспект расширяющих методов прямолинеен — эту возможность просто описать
и продемонстрировать. С другой стороны, рассуждать об их преимуществах (и плате за них) в
Глава 10. Расширяющие методы 315
категоричной манере труднее — это довольно эмоциональная тема, и разные люди просто обязаны
иметь отличающиеся точки зрения на ценность расширяющих методов.
В этой главе я попытался показать все понемногу. В самом начале мы взглянули на то, что
данное средство привносит в язык, а затем посмотрели на некоторые возможности, доступные в
инфраструктуре. В каком-то смысле это было плавным введением в LINQ: мы еще вернемся к
рассмотрению ряда методов, которые вы видели до сих пор, и ознакомимся с новыми методами,
когда углубимся в исследование выражений запросов в следующей главе.
В рамках класса Enumerable доступно широкое разнообразие методов, и в этой главе мы
лишь слегка коснулись поверхности. Забавно придумывать сценарий собственного изобретения
(будь то гипотетический или реальный проект) и просмотреть MSDN, чтобы узнать, что именно
из доступного могло бы помочь. Я призываю вас воспользоваться каким-нибудь искусственным
проектом и поиграть с описанными в главе расширяющими методами — это действительно больше
похоже на игру, нежели на работу, и вы, скорее всего, не захотите себя ограничивать только ме-
тодами, которые нужны для достижения самой непосредственной цели. В приложении А приведен
список стандартных операций запросов из LINQ, которые покрывают многие методы из класса
Enumerable.
В отрасли разработки программного обеспечения продолжают появляться новые шаблоны и
приемы, поэтому идеи из одних систем часто являются источниками идей в других системах. Это
одна из характерных особенностей, которые сохраняют процесс разработки настолько захваты-
вающим. Расширяющие методы позволяют записывать код таким способом, который ранее был
невозможным в С#, создавая текучие интерфейсы и изменяя среду для удовлетворения потреб-
ностей кода, а не наоборот. Это лишь те несколько приемов, которые рассматривались в данной
главе — всенепременно будут возникать интересные будущие разработки, использующие новые
средства С#, по отдельности или в комбинации.
Очевидно, революционное развитие на этом не заканчивается. Для некоторых вызовов расши-
ряющие методы хороши. В следующей главе мы займемся действительно мощными инструмента-
ми: выражениями запросов и полномасштабным LINQ.
ГЛАВА 11
В этой главе...
• Соединение и группирование
Возможно, к данному моменту вы уже устали от всех этих хвалебных од в адрес LINQ. Вы
уже видели некоторые примеры в книге и почти наверняка многое читали о LINQ в Интернете.
Именно здесь мы отделим мифы от реальности.
• LINQ не обещает, что вам никогда больше не придется снова видеть низкоуровневый код
SQL.
С учетом всего этого LINQ по-прежнему является наилучшим способом выражения запросов,
какой только мне приходилось видеть в рамках объектно-ориентированной среды. Конечно, это
не символ технологического прорыва, однако очень мощный инструмент, который стоит иметь в
своем арсенале средств разработки. Мы исследуем два отдельных аспекта LINQ: поддержку со
стороны инфраструктуры и трансляцию компилятором выражений запросов. Поначалу выражения
запросов могут выглядеть странными, но я уверен, что вы научитесь их любить.
Выражения запросов, в сущности, преобразуются компилятором в “нормальный” код C# 3,
который затем компилируется обычным образом. Это аккуратный способ интеграции запросов в
язык, требующий изменения всего лишь одного небольшого раздела спецификации. В большей
Глава 11. Выражения запросов и LINQ to Objects 317
Последовательности
На рис. 11.1 показано графическое представление этого выражения запроса, с разбиением его
на отдельные шаги.
Name="Holly", Age=36
Все объекты Person в Name="Tom", Age=9
people Name="Jon", Age=36
Name="William", Age=6
Name="Robin", Age=6
where person.Age >= 18
select person.Name
(Результат запроса)
Когда выражение запроса, показанное на рис. 11.1, только создается, никакие данные не обра-
Глава 11. Выражения запросов и LINQ to Objects 319
батываются. Доступ к исходному списку людей не производится вообще1 . Вместо этого в памяти
строится представление запроса. Для представления предиката, проверяющего совершеннолетие,
и преобразования объекта, хранящего сведения о человеке, в имя человека используются эк-
земпляры делегатов. Механизм начинает работать, только когда у результирующего экземпляра
IEnumerable<string> запрашивается первый его элемент.
Такой аспект LINQ называется отложенным выполнением. Когда запрашивается первый эле-
мент результата, трансформация Select() обращается за первым элементом к трансформации
Where(). Трансформация Where() запрашивает первый элемент у списка, проверяет его на со-
ответствие предикату (который в данном случае дает соответствие) и возвращает этот элемент
обратно Select(). В свою очередь, Select() извлекает имя и возвращает его в качестве ре-
зультата.
Вы можете испытывать чувство, близкое к дежа-вю, поскольку все это уже упоминалось в главе
10. Однако это настолько важная тема, что полезно раскрыть ее еще раз, но уже с большими
подробностями.
Как обычно, диаграмма последовательностей делает все намного яснее. Я свернул вызовы
MoveNext() и Current() в единственную операцию извлечения; это намного упрощает диа-
грамму. Просто запомните, что каждый раз, когда происходит извлечение, оно фактически также
проверяет, не закончилась ли последовательность. На рис. 11.2 представлено несколько этапов
выполнения выражения запроса при выводе элементов на консоль с помощью цикла foreach.
Как показано на рис. 11.2, производится обработка только одного элемента за раз. Если вы
решите остановить вывод на консоль после отображения строки Holly, то никаких операций над
другими элементами исходной последовательности выполняться не будет. Несмотря на наличие
здесь нескольких этапов, обработка данных в потоковой манере подобного рода является эффек-
тивной и гибкой. Безотносительно к объему исходных данных, в любой момент времени вы не
обязаны знать больше, чем об одном элементе.
Это наилучший сценарий. Временами извлечение первого результата запроса требует оцен-
ки всех данных из источника. Мы уже видели один такой пример в предыдущей главе: методу
Enumerable.Reverse() нужно было извлечь все доступные данные, чтобы возвратить послед-
ний элемент исходной последовательности в качестве первого элемента результирующей последо-
вательности. Это делает метод Reverse() буферизирующей операцией, которая может оказывать
большое влияние на эффективность (или даже осуществимость) всего действия. Если вы не може-
те позволить себе держать все данные в памяти одновременно, то использовать буферизирующие
операции не получится.
Подобно тому, как организация потока зависит от интересующей операции, некоторые транс-
формации будут осуществляться по мере обращения к ним, а не с применением отложенного
выполнения. Это называется немедленным выполнением. Говоря в общем, операции, которые воз-
вращают другую последовательность (обычно IEnumerable<T> или IQueryable<T>) исполь-
зуют отложенное выполнение, тогда как операции, возвращающие одиночное значение, применяют
немедленное выполнение.
Операции, широко доступные в LINQ, известны под названием стандартных операций запро-
сов — давайте кратко взглянем на них.
1
Хотя проверяются на предмет null различные задействованные параметры. Это важно иметь в виду при реали-
зации собственных операций LINQ, как вы увидите в главе 12.
Глава 11. Выражения запросов и LINQ to Objects 320
Вызывающий код
Select Where Список
(foreach)
Извлечение
Извлечение Извлечение
Извлечение
Извлечение
Извлечение
Возврат {"Тот", 9}
Трансформация:
{"Holly", 36} => "Holly"
Возврат "Jon"
Вывод на консоль
"Jon"
(и т.д.)
Только тот факт, что стандартные операции запросов имеют общий универсальный смысл, вовсе
не означает, что они будут работать в точности одинаковым образом в каждой реализации. На-
пример, некоторые поставщики LINQ могут загружать данные для всего запроса, когда им нужен
первый элемент — при обращении к веб-службе это вполне адекватно. Аналогично, запрос, кото-
рый работает в LINQ to Objects, может обладать слегка отличающейся семантикой в LINQ to SQL.
Это не значит, что LINQ обманул ожидания, просто при написании запроса необходимо принимать
во внимание, к какому источнику данных производится доступ. По-прежнему существует большое
преимущество в наличии единого набора операций запросов и согласованного синтаксиса самих
запросов, хотя это отнюдь не является панацеей.
В C# 3 имеется поддержка для ряда стандартных операций запросов, встроенная в язык по-
средством выражений запросов, но вы всегда можете принять решение вызывать их вручную, если
считаете, что это делает код яснее. Возможно, вам интересно будет узнать, что в языке VB9 при-
сутствует большее количество операций; как всегда, существует компромисс между добавочной
сложностью из-за включения средства в язык и преимуществами, которые привносит это средство.
Лично я думаю, что команда проектировщиков C# проделала замечательную работу; я всегда был
сторонником относительно небольшого языка и крупной библиотеки, сопровождающей его.
Перегрузка операций
Термин операция используется для описания и операций запросов (методов вроде Select() и
Where()), и знакомых операций, таких как сложение, равенство и т.д. Обычно то, какая разно-
видность операций имеется в виду, должно быть ясным из контекста — если речь идет о LINQ, то
операция почти всегда будет относиться к методу, применяемому как часть запроса.
2
Отдел маркетинга в SkeetySoft не особенно креативен.
Глава 11. Выражения запросов и LINQ to Objects 323
Pr
ojec
t Not
if
ic
ati
onS
ubs
cri
pti
on
Cl
ass Cl
ass
Pr
oje
ct
Pr
opert
ies Pr
ope
rti
es
Name E
mai
l
Addr
ess
Pr
oje
ct
S
tat
us De
fec
t S
ever
it
y
E
num Cl
ass E
num
S
eve
rit
y
Cr
e at
ed S
tat
us Pr
ope
rti
es Tri
vi
al
Accepted Cre
ate
d Minor
Fi
xed I
D Ma j
or
Reopened l
ast
Modif
ied Showst
oppe
r
Cl
ose d Summary
As
sig
nedT
o Cr
eat
edBy
User Us
erT
ype
Class E
num
Us
erT
ype
Properties Cust
omer
Name Devel
oper
Tes
ter
Manager
всех пользователей.
Часть элемент — это просто идентификатор с необязательным именем типа перед ним. В
большинстве случаев имя типа указывать не требуется, и в первом примере оно отсутствует.
Часть источник является обычным выражением. После этой первой конструкции может много
чего происходить, но рано или поздно встретится конструкция select или group. В целях
простоты мы начнем с конструкции select. Синтаксис конструкции select также прост:
select выражение
Выражение запроса выделено полужирным. Поскольку метод ToString() для всех сущностей
в модели был переопределен, результаты выполнения кода из листинга 11.1 выглядят следующим
образом:
Вас может интересовать, насколько полезен этот пример; в конце концов, можно было бы вос-
пользоваться свойством SampleData.AllUsers напрямую внутри оператора foreach. Однако
мы будем применять такое, пусть и простое, выражение запроса для представления двух новых
концепций. Сначала мы взглянем на общую природу процесса трансляции, который компилятор
использует, когда сталкивается с выражением запроса, а затем обсудим переменные диапазонов.
Глава 11. Выражения запросов и LINQ to Objects 325
Компилятор C# 3 транслирует выражение запроса в именно такой код, прежде чем присту-
пить к его компиляции. В частности, он не предполагает, что должен использоваться метод
Enumerable.Select() или что тип List<T> должен содержать метод по имени Select().
Пока код просто транслируется, а второй этап компиляции будет иметь дело с поиском подходя-
щего метода — будь это обычный член класса или расширяющий метод3 . Параметр может иметь
подходящий тип делегата либо Expression<T> для соответствующего типа Т.
Именно здесь становится важным тот факт, что лямбда-выражения могут быть преобразованы
в экземпляры делегатов и деревья выражений. Во всех примерах этой главы будут применяться
делегаты, но вы увидите, как использовать деревья выражений, при рассмотрении других постав-
щиков LINQ в главе 12. Когда я представляю сигнатуры для методов, вызываемых компилятором
позже, помните, что они относятся только к LINQ to Objects — всякий раз, когда параметр имеет
тип делегата (как в большинстве случаев), компилятор будет применять в качестве аргумента
лямбда-выражение и затем пытаться отыскать метод с подходящей сигнатурой.
Также важно помнить, что везде, где в лямбда-выражении обнаруживается обычная переменная
(такая как локальная переменная внутри метода) после того, как трансляция выполнена, она
станет захваченной переменной в том же стиле, как было показано в главе 5. Это нормальное
поведение лямбда-выражения, но если вы не понимаете, какие переменные будут захватываться,
то возникнет риск получить неожиданные результаты из запросов.
В спецификации языка предоставляются подробные сведения о шаблоне выражений запросов,
который должен быть реализован всеми выражениями запросов, чтобы они были работоспособ-
ными, однако он не определен в виде интерфейса, как можно было ожидать. Это очень разумно:
такой подход позволяет LINQ быть применимым к интерфейсам наподобие IEnumerable<T> с
использованием расширяющих методов. В этой главе по очереди рассматриваются все элементы
3
На самом деле все даже более универсально — компилятор не требует, чтобы Select() был методом или
SampleData.AllUsers — свойством. До тех пор, пока транслированный код компилируется, все в порядке. По-
чти в каждом разумном случае вы будете получать доступ либо к стандартному, либо к расширяющему мето-
ду, но в своем блоге я описал несколько довольно необычных запросов, которые вполне устраивают компилятор
(http://mng.bz/7E3i). Я не считаю, что запросы подобного рода полезны на практике, однако мне нравится при-
водить эти пример в качестве иллюстрации, насколько механическим является сам процесс трансляции и до какой
степени компилятор не заботит смысл транслированного кода.
Глава 11. Выражения запросов и LINQ to Objects 326
В результате запуска кода из листинга 11.3 на консоль выводится строка Where called и
затем строка Select called, как и можно было ожидать, поскольку выражение запроса было
транслировано в следующий код:
var query = source.Where(dummy => dummy.ToString() == "Ignored")
.Select(dummy => "Anything");
Разумеется, здесь не выполняется какой-то запрос или трансформация, но можно понять,
Глава 11. Выражения запросов и LINQ to Objects 327
как компилятор транслирует выражение запроса. Если затрудняетесь сказать, почему лямбда-
выражение в вызове Select() возвращает “Anything”, а не просто dummy, то это потому, что в
данном конкретном случае компилятор удалил проекцию dummy (как ничего не делающую). Мы
разберем это в разделе 11.3.2, а пока важная идея касается в целом вида задействованной транс-
ляции. Необходимо только знать, какие трансляции компилятор C# будет использовать, после
чего вы сможете взять любое выражение запроса, преобразовать его в форму, в которой лямбда-
выражения не применяются, и затем посмотреть на то, что оно делает, с этой точки зрения.
Обратите внимание, что интерфейс IEnumerable<T> вообще не реализован в Dummy<T>.
Трансляция выражений запросов в нормальный код от этого не зависит, но на практике большин-
ство поставщиков LINQ будут открывать доступ к данным либо через IEnumerable<Т>, либо
через IQueryable<T> (последний из двух будет описан в главе 12). Тот факт, что трансляция
не зависит от каких-нибудь конкретных типов, а полагается только на имена и параметры мето-
дов, можно считать разновидностью утиной типизации на этапе компиляции. Похожим образом
инициализаторы коллекций, представленные в главе 8, ищут открытый метод по имени Add(),
используя обычное распознавание перегруженных версий, а не интерфейс, который содержит ме-
тод Add() с определенной сигнатурой. Выражения запросов продвигают эту идею еще дальше —
трансляция происходит на раннем этапе процесса компиляции, чтобы позволить компилятору вы-
брать либо методы экземпляра, либо расширяющие методы. Можете даже считать трансляцию как
работу отдельного препроцессорного механизма.
Вам может показаться, что я слишком надоедливо веду речь об этом, но все делается ради
снятия завесы, которая иногда покрывает LINQ. Если вы перепишете выражение запросов в виде
последовательности обращений к методам, фактически сделав то, что предпринял бы компилятор,
то тем самым никак не измените производительность или поведение запроса. Это просто два
разных способа представления одного и того же кода.
Теперь вам известно о том, что задействуется трансляция на уровне исходного кода, однако
есть еще одна критически важная концепция, которую следует понять, прежде чем можно будет
двигаться дальше.
Объявление
переменной Исходное выражение
диапазона (нормальный код)
select user
Выражение,
Контекстные ключевые
использующее
слова выражения запроса
переменную
диапазона
Контекстные ключевые слова объяснить легко — они указывают компилятору, что именно нуж-
но делать с данными. Подобным же образом исходное выражение является обычным выражением
C# — в данном случае свойством, но с той же легкостью оно могло бы быть вызовом метода или
переменной.
Сложности начинаются, когда дело доходит до объявления переменной диапазона и выражения
проекции. Переменные диапазонов не похожи на переменные других видов. В некотором смысле
это вообще не переменные! Они доступны только в выражениях запросов и фактически пред-
назначены для распространения контекста из одного выражения в другое. Они представляют по
одному элементу отдельной последовательности за раз и применяются при трансляции, выполняе-
мой компилятором, для облегчения перевода других выражений в лямбда-выражения.
Вы уже видели, что исходное выражение запроса было преобразовано следующим образом:
В этот момент компилятор откажется компилировать код, т.к. ему не известно, на что ссыла-
ется person.
Теперь, когда вы знаете, насколько прост процесс, становится легче понять выражение запроса,
которое содержит несколько более сложную проекцию. Код в листинге 11.4 выводит на консоль
только имена пользователей.
Глава 11. Выражения запросов и LINQ to Objects 329
Компилятор разрешает это, т.к. выбранный расширяющий метод Select() из класса Enumerable
имеет такую сигнатуру4 :
До сих пор мы видели только неявно типизированные переменные диапазонов. А что произой-
дет, если в объявление включить тип? Ответ кроется в стандартных операциях запросов Cast()
и OfType().
4
Чтобы сигнатуры всех методов, упоминаемых в этой главе, умещались в печатную страницу, я не указываю в
них модификатор public. На деле все они являются открытыми.
Глава 11. Выражения запросов и LINQ to Objects 330
Листинг 11.5. Применение операций Cast() и OfType() для работы со слабо типизирован-
ными коллекциями
Первый список содержит только строки, поэтому использовать Cast<string>() для полу-
чения последовательности строк вполне безопасно. Второй список имеет смешанное содержимое,
так что для извлечения из него только целых чисел применяется OfType<int>(). Использование
Cast<int>() для второго списка привело бы к генерации исключения при попытке приведения
“не int” к int. Обратите внимание, что это произошло бы только после вывода на консоль 1 —
обе операции организуют поток для данных, преобразуя элементы по мере их извлечения.
В .NET 3.5 SP1 поведение операции Cast() слегка изменилось. В исходной платформе .NET 3.5
она выполнила бы больше преобразований, поэтому применение Cast<int>() к List<short>
преобразовало бы каждый элемент типа short в тип int при его извлечении. В .NET 3.5 SP1 и
Глава 11. Выражения запросов и LINQ to Objects 331
всех последующих выпусках это привело бы к генерации исключения. Если необходимо любое пре-
образование, отличное от ссылочного или распаковывающего (или преобразования идентичности,
не выполняющего никаких действий), используйте вместо операции Cast() проекцию Select().
Операция OfТуре() также выполняет только эти преобразования, но не генерирует исключение в
случае отказа.
Когда вводится переменная диапазона с явным типом, компилятор применяет вызов Cast(),
чтобы обеспечить подходящий тип для последовательности, используемой остальной частью выра-
жения запроса. Это иллюстрируется кодом в листинге 11.6, в котором проекция применяет метод
Substring() для доказательства того, что последовательность, генерируемая конструкцией from,
является последовательностью строк.
Код из листинга 11.6 выводит на консоль Fir, Sec, Thi, но более интересно взглянуть на
транслированное выражение запроса:
list.Cast<string>().Select(entry => entry.Substring(0,3));
Без приведения вызов Select() был бы вообще невозможен, т.к. расширяющий метод опре-
делен только для IEnumerable<T>, но не IEnumerable. Даже когда применяется строго типи-
зированная коллекция, может по-прежнему требоваться указывать явно типизированную перемен-
ную диапазона. Например, при наличии коллекции, определенной как List<ISomeInterface>,
может быть известно, что все ее элементы являются экземплярами типа MyImplementation.
Использование переменной диапазона с явным типом MyImplementation позволяет получать
доступ ко всем членам MyImplementation без ручной вставки приведений повсеместно в коде.
К настоящему моменту было раскрыто множество важных концепций, даже при условии, что
пока еще не получены сколько-нибудь впечатляющие результаты. Ниже кратко описаны наиболее
важные из них.
• Язык LINQ основан на последовательностях данных, которые организуются в потоки всегда,
когда это возможно.
where выражение-фильтра
Теперь нет нужды в финальном вызове Select(), поэтому транслированный код становится
таким:
Данные правила редко становятся препятствием при написании выражений запросов, но они
могут вызвать путаницу, если вы декомпилируете код с помощью инструмента, подобного Reflector,
и обнаружите, что обращение к Select() по непонятной причине отсутствует.
Вооружившись этими знаниями, самое время усовершенствовать запрос, чтобы узнать, над чем
Тим должен работать далее.
Вывод кода из листинга 11.8 показывает, что результат был отсортирован нужным образом:
В наличии два крупных дефекта. В каком порядке их устранять? Пока что никакого четкого
порядка не предусмотрено.
Давайте изменим запрос так, чтобы после нисходящей сортировки по степени серьезности
выполнялась восходящая сортировка по времени последнего изменения. Это означает, что Тим
будет проверять сначала дефекты, которые были зафиксированы давным-давно, и только затем
относительно недавние дефекты. Для этого потребуется лишь одно дополнительное выражение в
конструкции orderby, как показано в листинге 11.9.
Результаты выполнения кода из листинга 11.9 приведены ниже. Обратите внимание, что поря-
док следования двух крупных дефектов изменился на противоположный:
Вот так выглядит выражение запроса, но что конкретно делает компилятор? Он просто вы-
зывает методы OrderBy() и ThenBy() (или OrderByDescending()/ ThenByDescending()
для упорядочения по убыванию). Выражение запроса транслируется следующим образом:
Разница между OrderBy() и ThenBy проста: метод OrderBy() принимает на себя ответ-
ственность за первичный контроль над упорядочением, тогда как метод ThenBy() служит сред-
ством для выполнения одного или более последующих упорядочений. В LINQ to Objects метод
ThenBy() определен только как расширяющий для интерфейса IOrderedEnumerable<T>, ко-
торый представляет собой тип, возвращаемый методом OrderBy() (и самим методом ThenBy(),
чтобы позволить дальнейшее выстраивание в цепочку).
Важно отметить, что хотя допускается использовать несколько конструкций orderby, каждая
из них будет начинаться с собственного вызова OrderBy() или OrderByDescending(), и это
означает, что последняя, в сущности, выиграет. Я еще не сталкивался с ситуацией, когда это бы
пригодилось, если только не предположить, что между конструкциями оrderby внутри запроса
делается что-то еще; взамен вы почти всегда должны применять единственную конструкцию,
содержащую несколько порядков.
Как было показано в главе 10, выполнение упорядочения требует загрузки всех данных (во
всяком случае, для LINQ to Objects) — к примеру, упорядочить бесконечную последовательность
невозможно. Надеюсь, причина для вас очевидна: нельзя узнать, какой элемент должен попасть в
самое начало, до тех пор, пока не станут доступными все элементы.
Мы находимся примерно на середине пути по изучению выражений запросов, и вас может уди-
вить, что мы пока еще встречали ни одного соединения. Очевидно, что соединения важны в LINQ,
равным образом как они важны в SQL, однако они также и сложны. Я обещаю, что в свое время
мы до них доберемся, но чтобы представлять по одной концепции за раз, мы выберем окольный
путь и рассмотрим сначала конструкции let. Это позволит обсудить прозрачные идентификаторы,
прежде чем мы вплотную займемся соединениями.
Чтобы объяснить эту операцию в терминах, не касающихся более сложных операций, я при-
бегну к весьма надуманному примеру. Оставьте в покое недоверие и вообразите, что нахождение
длины строки является дорогостоящей в плане ресурсов операцией. А теперь представьте, что име-
ется совершенно странное системное требование о том, что пользователи должны упорядочиваться
по именам и вместе с именем отображалось его длина. Да, я знаю, что подобное маловероятно.
Глава 11. Выражения запросов и LINQ to Objects 337
Тем не менее, в листинге 11.10 продемонстрирован способ реализации этого без применения кон-
струкции let.
В листинге 11.11 вводится новая переменная диапазона по имени length, которая содержит
длину имени пользователя (для текущего пользователя в исходной последовательности). Затем эта
новая переменная диапазона используется для сортировки и в конце для проецирования. Вы уже
заметили проблему? Необходимо применять две переменные диапазонов, но лямбда-выражение,
передаваемое в Select(), принимает только один параметр! Здесь и вступают в игру прозрачные
идентификаторы.
from user in
SampleData.AllUsers
User: { Name="Tim Trotter" ... }
User: { Name="Tara Tutu" ... }
User: { Name="Dave Denton" ... }
...
orderby length
(Результат запроса)
Рис. 11.5. Последовательности, задействованные в листинге 11.11, где конструкция let вводит
переменную диапазона length
SampleData.AllUsers
.Select(user => new { user, length = user.Name.Length })
.OrderBy(z => z.length)
.Select(z => new { Name = z.user.Name, Length = z.length })
Каждая часть запроса была соответствующим образом подкорректирована: там, где в исходном
выражении запроса присутствовала ссылка непосредственно на user или length, но после кон-
струкции let, она была заменена ссылкой z.user или z.length. Выбор z в качестве имени
совершенно произволен — это имя скрывается компилятором.
Глава 11. Выражения запросов и LINQ to Objects 339
11.5 Соединения
Если вам когда-либо приходилось читать что-нибудь о языке SQL, то вероятно вы представля-
ете себе, что такое операция соединения в базе данных. Она берет две таблицы (или представле-
ния, табличные функции и т.д.) и создает результат, сопоставляя один набор строк с другим таким
набором. Соединение в LINQ выглядит похожим за исключением того, что работает на последова-
тельностях. Доступны три типа соединений, хотя не все они используют ключевое слово join в
выражении запроса. Первым делом мы рассмотрим соединение, которое очень близко напоминает
внутреннее соединение в SQL
5
Также допускается использование двух типов ключей, если доступно неявное преобразование из одного типа в
другой. Один из типов должен быть лучшим выбором, чем другой, как это делается при выведении компилятором
типа для неявно типизированного массива. Хотя в моей практике редко приходилось преднамеренно учитывать эту
деталь.
Глава 11. Выражения запросов и LINQ to Objects 341
(Результат запроса)
Рис. 11.6. Соединение из листинга 11.12 в графической форме, иллюстрирующей использование двух
разных последовательностей (дефектов и подписок на уведомления) как источников данных
В SQL внутренние соединения применяются постоянно. Фактически они представляют собой спо-
соб навигации от одной сущности к другой, связанной с ней сущности, обычно с выполнением со-
единения внешнего ключа одной таблицы с первичным ключом другой. В объектно-ориентированной
модели навигация между объектами, как правило, осуществляется с помощью ссылок. Например,
извлечение сводной информации по дефекту и имени пользователя, который назначен для работы
над ним, в SQL потребовало бы соединения — с другой стороны, в C# часто можно использовать
цепочку свойств. Если бы в модели имелась обратная ассоциация между свойством Project и
списком связанных с ним объектов NotificationSubscription, то для достижения цели этого
примера не пришлось бы применять соединение. Это не говорит о том, что внутренние соединения
временами не оказываются полезными внутри объектно-ориентированных моделей, но они не воз-
никают естественным образом настолько часто, как это бывает в реляционных моделях.
leftSequence.Join(rightSequence,
leftKeySelector,
rightKeySelector,
resultSelector)
Глава 11. Выражения запросов и LINQ to Objects 343
Первые три параметра самоочевидны, если вы не забыли трактовать inner (внутренняя после-
довательность) и outer (внешняя последовательность) как правую и левую последовательности,
но последний параметр более интересен. Он является проекцией из двух элементов (одного из
левой последовательности и одного из правой последовательности) в единственный элемент ре-
зультирующей последовательности.
Когда за соединением следует что-то, отличное от конструкции select, то компилятор C# 3
вводит прозрачный идентификатор, чтобы сделать переменные диапазонов, применяемые в обеих
последовательностях, доступными для последующих конструкций, а также создает анонимный тип
и простое отображение для использования с параметром resultSelector.
Но если следующей частью выражения запроса является конструкция select, проекция из
конструкции select применяется напрямую как параметр resultSelector — не имеет смысла
создавать пару и затем вызывать метод Select(), в то время как трансформацию можно сделать
за один шаг. Вы по-прежнему можете воспринимать это как шаг “соединения”, за которым следу-
ет шаг “выборки”, несмотря на то, что они спрессованы в одиночный вызов метода. На мой взгляд,
это приводит к более согласованной умозрительной модели, рассуждать о которой намного про-
ще. Если вы не просматриваете сгенерированный код, просто проигнорируйте эту оптимизацию,
выполненную компилятором.
Хорошая новость заключается в том, что после изучения внутренних соединений вы сочтете
следующий тип соединения намного более простым в освоении.
6
Эта простая реализация может выступать только в качестве примера и не является полнофункциональной или
универсальной версией.
Глава 11. Выражения запросов и LINQ to Objects 345
выполняться не будет. Ниже показана первая часть результатов, генерируемых кодом из листинга
11.14, в которых отображено число обнаруженных дефектов ежедневно в течение первой недели
мая:
05/01/2013: 1
05/02/2013: 0
05/03/2013: 2
05/04/2013: 1
05/05/2013: 0
05/06/2013: 1
05/07/2013: 1
Компилятор транслирует групповое соединение в вызов метода GroupJoin() аналогично тому,
как внутреннее соединение транслируется в вызов Join(). Вот как выглядит сигнатура метода
Enumerable.GroupJoin():
static IEnumerable<TResult> GroupJoin<TOuter, TInner, TKey, TResult> (
this IEnumerable<TOuter> outer,
IEnumerable<TInner> inner,
Func<TOuter,TKey> outerKeySelector,
Func<TInner,TKey> innerKeySelector,
Func<TOuter,IEnumerable<TInner>,TResult> resultSelector
)
Сигнатура в точности такая же, как для внутренних соединений, за исключением того, что па-
раметр resultSelector должен работать с последовательностью элементов, находящихся спра-
ва, а не с единственным элементом. Как и во внутренних соединениях, если за групповым со-
единением следует конструкция select, в качестве селектора результата вызова GroupJoin()
применяется проекция; в противном случае вводится прозрачный идентификатор. В рассматри-
ваемой ситуации конструкция select находится непосредственно после группового соединения,
поэтому транслированный запрос выглядит следующим образом:
dates.GroupJoin(SampleData.AllDefects,
date => date,
defect => defect.Created.Date,
(date, joined) => new { Date = date,
Count = joined.Count() })
Последний тип соединений называется перекрестным соединением, и он не так прост, как
может показаться на первый взгляд.
Две переменные диапазона: user = User (Tim Trotter), project = Project (Media Player)
каждый проект попарно user = User (Tim Trotter), project = Project (Talk)
соединяется с каждым user = User (Tim Trotter), project = Project (Office)
пользователем user = User (Tara Tutu), project = Project (Media Player)
user = User (Tara Tutu), project = Project (Talk)
user = User (Tara Tutu), project = Project (Office)
...
(Результат запроса)
Код в листинге 11.16 начинается с создания простого диапазона целых чисел от 1 до 4. Для
каждого числа создается другой диапазон, который начинается с 11 и имеет количество элемен-
тов, равное исходному целому числу. С помощью нескольких конструкций from левая последо-
вательность соединяется с каждой генерируемой правой последовательностью, давая в результате
следующий вывод:
Left=1; Right=11
Глава 11. Выражения запросов и LINQ to Objects 349
Left=2; Right=11
Left=2; Right=12
Left=3; Right=11
Left=3; Right=12
Left=3; Right=13
Left=4; Right=11
Left=4; Right=12
Left=4; Right=13
Left=4; Right=14
Для генерации этой последовательности компилятор вызывает метод SelectMany(). Он при-
нимает единственную входную последовательность (левая последовательность в нашей термино-
логии), делегат для генерации другой последовательности из любого элемента левой последо-
вательности и делегат для генерации результирующего элемента на основе элемента из каждой
последовательности. Вот как выглядит сигнатура метода Enumerable.SelectMany():
static IEnumerable<TResult> SelectMany<TSource,TCollection,TResult>(
this IEnumerable<TSource> source,
FunccTSource,IEnumerable<TCollection>> collectionSelector,
Func<TSource,TCollection,TResult> resultSelector
)
Как и с другими видами соединений, если за частью, касающейся соединения, в выражении за-
проса следует конструкция select, эта проекция используется в качестве последнего аргумента;
иначе вводится прозрачный идентификатор, чтобы сделать переменные диапазонов левой и правой
последовательностей доступными позже в запросе.
Просто чтобы чуть конкретизировать сказанное, ниже приведено выражение запроса из ли-
стинга 11.16 в виде транслированного исходного кода:
Enumerable.Range(1, 4)
.SelectMany(left => Enumerable.Range(11, left),
(left, right) => new {Left = left, Right = right})
Одной интересной особенностью метода SelectMany() является полностью потоковое вы-
полнение — в любой момент времени необходимо обрабатывать только один элемент, поскольку
для каждого отдельного элемента из левой последовательности применяется заново сгенерирован-
ная правая последовательность. Сравните это с внутренними и групповыми соединениями: перед
началом возвращения результатов указанные виды соединений полностью загружают правую по-
следовательность.
Поведение выравнивания SelectMany() может оказаться весьма удобным. Возьмем ситуа-
цию, когда нужно обработать множество журнальных файлов построчно. Бесшовную последова-
тельность строк можно обрабатывать практически любым способом. Показанный ниже псевдокод
более подробно представлен в загружаемом коде, но его общий смысл и польза должны быть
вполне очевидными:
var query = from file in Directory.GetFiles(logDirectory, "*.log")
from line in ReadLines(file)
let entry = new LogEntry(line)
where entry.Type == EntryType.Error
select entry;
С помощью всего пяти строк кода имеется возможность извлечь, проанализировать и отфиль-
тровать целую коллекцию журнальных файлов, возвращая последовательность записей, которые
Глава 11. Выражения запросов и LINQ to Objects 350
представляют ошибки. Важно отметить, что ни один журнальный файл не пришлось загружать
полностью в память за раз, не говоря уже о загрузке всех файлов — все данные организованы в
поток.
По сравнению с соединениями последние элементы, которые необходимо рассмотреть, несколь-
ко проще для понимания. Мы взглянем на элементы группирования по ключу и на продолжение
выражения запроса после конструкции group...by или select.
{
Console.WriteLine(" ({0}) {1}",
defect.Severity, defect.Summary);
}
Console.WriteLine();
}
Код в листинге 11.17 может оказаться удобным для построения ежедневных отчетов, позво-
ляющих быстро просмотреть, на какие дефекты должны обращать внимание ответственные за их
устранение. Он отфильтровывает дефекты, которые больше не нуждаются во внимании Ê, и затем
осуществляет группирование с применением свойства AssignedTo. Хотя в этот раз используется
свойство, выражение группирования может быть любым — оно применяется к каждой записи во
входящей последовательности, а последовательность группируется на основе результата этого вы-
ражения. Обратите внимание, что группирование не может обеспечить организацию потоков для
результатов; оно применяет селектор ключей и проекцию к каждому элементу во входной после-
довательности и буферизирует сгруппированные последовательности проецированных элементов.
Несмотря на то что для этого не организуется поток, выполнение по-прежнему откладывается до
тех пор, пока не начнется извлечение результатов.
Применяемое при группировании Ë проецирование тривиально — оно лишь выбирает исходный
элемент. По мере прохождения по результирующей последовательности выясняется, что каждая
запись имеет свойство Key типа User Ì, а также реализует интерфейс IEnumerable<Defect>,
который представляет последовательность дефектов, назначенных для исправления данному поль-
зователю Í.
Результаты выполнения кода из листинга 11.17 начинаются так:
Darren Dahlia
(Showstopper) MP3 files crash system
(Major) Can't play files more than 200 bytes long
(Major) DivX is choppy on Pentium 100
(Trivial) User interface should be more caramelly
После вывода на консоль всех дефектов, назначенных для исправления Даррену, будут вы-
ведены дефекты, назначенные Таре, Тиму и т.д. В сущности, реализация сохраняет список на-
значенных исполнителей, показанный до сих пор, и добавляет нового назначенного исполнителя
каждый раз, когда это необходимо. На рис. 11.9 показаны последовательности, сгенерированные
на протяжении всего выражения запроса, что может прояснить это упорядочение.
Внутри вложенной последовательности каждой записи порядок следования дефектов совпадает
с порядком их следования в исходной последовательности дефектов. Если вы всерьез проявляете
интерес к упорядочению, подумайте о явном его указании в выражении запроса, сделав его более
читабельным.
Если вы запустите код из листинга 11.17, то заметите, что записи для Мэри (Маrу Malcop)
в выводе вообще отсутствуют, поскольку ей не назначены какие-либо дефекты для исправления.
Если необходимо построить полный список пользователей и дефектов, назначенных им для ис-
правления, придется применять групповое соединение, подобное показанному в листинге 11.14.
Глава 11. Выражения запросов и LINQ to Objects 352
(Результат запроса)
Отличия между листингами 11.18 и 11.17 выделены полужирным. Поскольку каждый дефект
проецируется в его сводную информацию, встроенной последовательностью внутри каждой записи
будет IEnumerable<string>. В этом случае компилятор применяет перегруженную версию
метода GroupBy() с другим параметром, представляющим проекцию. Выражение запроса из
листинга 11.18 транслируется в следующее выражение:
SampleData.AllDefects.Wherefdefect => defect.AssignedTo != null)
.GroupBy(defect => defect.AssignedTo,
defect => defect.Summary)
Конструкции группирования относительно просты, но вместе с тем полезны. Даже в системе
регистрации дефектов можно легко обосновать желание группировать дефекты по проекту, созда-
телю, уровню серьезности или состоянию, равно как и по назначенному исполнителю, как было в
рассмотренных примерах.
До сих пор каждое выражение завершалось конструкцией select или group...by, и это
было концом выражения. Однако иногда требуется выполнить дополнительные действия над ре-
зультатами, для чего и предназначено продолжение запроса.
Очень легко попасть в ловушку, думая, что всегда, когда встречается контекстное ключевое
слово into, оно обозначает продолжение. В случае соединений это неверно — конструкция
join...into (используемая для групповых соединений) не формирует продолжение. Важное от-
личие заключается в том, что при групповом соединении все ранее введенные переменные диапазо-
нов (кроме той, что применяется для именования правой стороны соединения) могут по-прежнему
использоваться. Сравните это с запросами, рассматриваемыми в настоящем разделе, где продол-
жение начинает все с начала; впоследствии доступна только переменная диапазона, объявленная
продолжением.
просто было применено упорядочение. На этот раз транслированное выражение запроса выглядит
следующим образом:
SampleData.AllDefects
.Where(defect => defect.AssignedTo != null)
.GroupBy(defect => defect.AssignedTo)
.Select(grouped => new { Assignee = grouped.Key,
Count = grouped.Count() })
.OrderByDescending(result => result.Count);
Существует аналогичная перегруженная версия и для метода Select(), поэтому если вам
необходима возможность получить исходный индекс в последовательности после упорядочения,
понадобится написать такой код:
7
С этого момента я буду использовать данный термин, но если вы слышите, как другие говорят о текучей
нотации, скорее всего, они имеют в виду то же самое.
Глава 11. Выражения запросов и LINQ to Objects 357
В IDE-среде было бы разумно разместить всю конструкцию join в одной строке, получив
тем самым более читабельный код. С другой стороны, эквивалент запроса в точечной нотации
выглядит довольно непривлекательно:
SampleData.AllDefects.Join(SampleData.AllSubscriptions,
defect => defect.Project,
subscription => subscription.Project,
(defect, subscription) => new { defect.Summary,
subscription.EmailAddress })
Последний аргумент мог бы уместиться в одну строку в IDE-среде, но он все равно бы вы-
глядел неуклюже, поскольку лямбда-выражения не содержат особенно много контекста; вы не
сумеете немедленно сказать, что означает тот или иной аргумент. Здесь могут помочь именован-
ные аргументы из C# 4, но это даже больше увеличивает объем запроса. Сложные упорядочения
могут быть аналогично неприглядными в точечной нотации.
Посмотрите, что лучше читается — следующая конструкция orderby:
Глава 11. Выражения запросов и LINQ to Objects 359
11.8 Резюме
В этой главе мы рассмотрели взаимодействие LINQ to Objects и C# 3, уделив основное вни-
мание первоначальной трансляции выражений запросов в код, в котором выражения запросов не
задействованы и который затем компилируется обычным способом. Вы видели, что все выражения
запросов формируют набор последовательностей, на каждом шаге преобразуя некоторые описания.
Во многих случаях эти последовательности оцениваются с применением отложенного выполнения,
при котором данные извлекаются только при первом их запрашивании.
По сравнению со всеми остальными средствами C# 3 выражения запросов выглядят несколько
чужеродными — они больше похожи на SQL, чем на привычный код С#. Одна из причин, по
которым выражения запросов имеют настолько странный вид, связана с тем, что они являются
декларативными, а не императивными — запрос сообщает о характеристиках результата, а не о
точных шагах, необходимых для его получения. Это идет рука об руку с более функциональным
стилем мышления. Его понимание может потребовать определенного времени, к тому же оно не
подходит абсолютно во всех ситуациях, но там, где декларативный синтаксис уместен, значительно
улучшается читабельность, а также упрощается тестирование и распараллеливание кода.
Не вводите себя в заблуждение, думая, что язык LINQ должен использоваться только с базами
данных. Распространенным его применением является манипулирование коллекциями в памяти,
вдобавок вы видели, насколько хорошо он поддерживается выражениями запросов и расширяю-
щими методами в классе Enumerable.
На самом деле вы ознакомились со всеми средствами, появившимися в C# 3! Пока еще не
были показаны какие-то другие поставщики LINQ но вы уже лучше понимаете, что компилятор
будет делать, когда вы запросите у пего обработку XML и SQL Самому компилятору ничего не
известно о разнице между LINQ to Objects, LINQ to SQL или любыми другими поставщиками;
он всего лишь вслепую следует одним и тем же правилам.
В следующей главе вы увидите, как эти правила формируют финальный фрагмент головолом-
ки LINQ когда они преобразуют лямбда-выражения в деревья выражений, позволяя различным
конструкциям выражений запросов выполняться на разных платформах. Вы также ознакомитесь
с другими примерами того, что может делать LINQ.
ГЛАВА 12
В этой главе...
• LINQ to SQL
• LINQ to XML
• Parallel LINQ
Предположим, что инопланетянин попросил вас описать свою культуру. Каким образом можно
было бы охватить все многообразие человеческой культуры за короткий промежуток времени? Вы
могли бы решить, что лучше потратить это время на то, чтобы показать ему культуру, а не
описывать ее абстрактными понятиями: посетить новоорлеанский джаз-клуб, оперу Ла Скала,
выставку картин в Лувре, пьесу Шекспира в Стратфорде-на-Эйвоне и т.д.
Знал ли бы инопланетянин человеческую культуру впоследствии? Смог ли бы он сочинить
мелодию, написать книгу, танцевать балет, изваять скульптуру? Безусловно, нет. Но возможно у
него осталось бы восприятие культуры — ее богатства и разнообразия, ее способности оживлять
жизнь людей.
Так и с этой главой. Вы уже видели все средства C# 3, но без дополнительного материала по
LINQ вы не имеете достаточного контекста, чтобы по-настоящему оценить их. На время публи-
кации первого издания этой книги было доступно не особенно много технологий LINQ но теперь
их в избытке, как от Microsoft, так и от других производителей. Сам по себе этот факт меня не
удивляет, но я восхищен тем, насколько различается природа этих технологий.
Мы рассмотрим различные пути, которыми LINQ проявляет себя, и ознакомимся с соответ-
ствующими примерами. Я решил демонстрировать главным образом технологии от Microsoft, по-
скольку они являются наиболее типовыми. Однако из этого не стоит делать вывод, что продукты
других производителей не приветствуются в экосистеме LINQ: имеется несколько проектов, и
Глава 12. LINQ за рамками коллекций 361
Увидев упоминание “новых версий”, вас может удивить, по какой причине я выбрал для демонстра-
ции LINQ to SQL, а не инфраструктуру Entity Framework, которая теперь является предпочтитель-
ным решением Microsoft (к тому же она также поддерживает LINQ). Причина связана единственно
с простотой; инфраструктура Entity Framework во многих отношениях бесспорно мощнее, чем LINQ
to SQL, однако она требует знания дополнительных концепций, объяснение которых заняло бы
здесь слишком большой объем пространства. Я стараюсь дать вам представление о согласованно-
сти (а временами и о несогласованности), обеспечиваемой LINQ, и это применимо к LINQ to SQL,
а также к Entity Framework.
Перед тем, как приступить к написанию запросов, необходима база данных и модель для ее
представления в коде.
После того, как таблицы созданы, генерация сущностных классов в Visual Studio выпол-
няется легко. Просто откройте окно Server Explorer (Проводник сервера), выбрав пункт меню
View Õ Server Explorer (Вид Õ Проводник сервера), и добавьте источник данных для базы данных
SkeetySoftDefects (щелкнув правой кнопкой мыши на элементе Data Connections (Подключе-
ния к данным) и выбрав в контекстном меню пункт Add Connection (Добавить подключение)). Вы
должны видеть четыре таблицы: Defect, DefectUser, Project и NotificationSubscription.
Затем добавьте в проект новый элемент типа LINQ to SQL classes (Классы LINQ to SQL). Его
имя будет основой для сгенерированного класса, представляющего общую модель базы данных; я
применил имя DefectModel, которое приводит к созданию класса DefectModelDataContext.
После создания этого нового элемента откроется визуальный конструктор. Затем можно перета-
щить четыре таблицы из окна Server Explorer на поверхность визуального конструктора, который
выявит все ассоциации. После этого можно перепланировать диаграмму и подстроить различные
свойства сущностных классов. Ниже приведен список внесенных мною изменений.
• Я переименовал свойство DefectID в ID, чтобы оно соответствовало предыдущей модели.
• Я переименовал свойство DefectUser в User (так что хотя таблица по-прежнему называ-
ется DefectUser, будет сгенерирован класс по имени User, как и ранее).
• Я изменил тины свойств Severity, Status и UserType на эквиваленты их перечислений
(скопировав эти перечисления в проект).
• Я переименовал родительские и дочерние свойства, используемые для ассоциаций между
классами Defect и DefectUser — для других ассоциаций визуальный конструктор вы-
вел подходящие имена, но здесь потерпел неудачу, т.к. между одной парой таблиц оказа-
лось две ассоциации. Эти отношения получили имена AssignedTo/AssignedDefects и
CreatedBy/CreatedDefects.
На рис. 12.1 показана диаграмма классов в визуальном конструкторе после всех описанных
изменений. Как видите, она выглядит очень похожей на диаграмму классов на рис. 11.3, но без
перечислений.
Глава 12. LINQ за рамками коллекций 363
«
«
Project NotificationSubscription
Properties Properties
ProjectID NotificationSubscriptionID
Name ProjectID
EmailAddress
«
Defects
Properties
ID
Created
LastModified
Summary
Severity
Status
AssignedToUserID
CreatedByUserID
ProgectID
«
User
Properties
UserID
Name
UserType
Рис. 12.1. Визуальный конструктор классов LINQ to SQL отображает переделанные и модифицированные
сущности
Теперь, имея схему в SQL, сущностную модель в С# и образец данных, самое время присту-
пать к запросам.
Тривиальные примеры, приводимые в начале главы 11, здесь рассматриваться не будут, а нач-
нем мы сразу с запроса из листинга 11.7, который ищет открытые дефекты, назначенные для
исправления Тиму (Tim). В целях сравнения ниже представлена часть листинга 11.7, касающаяся
запроса:
User tim = SampleData.Users.TesterTim;
var query = from defect in SampleData.AllDefects
where defect.Status != Status.Closed
where defect.AssignedTo == tim
select defect.Summary;
Полный эквивалент кода из листинга 11.7, реализованного с помощью LINQ to SQL показан в
листинге 12.1.
Листинг 12.1. Запрашивание базы данных для нахождения всех открытых дефектов, назна-
ченных для исправления Тиму
User tim = context.Users ❸ Запрашивание базы данных с целью нахождения записи для Тима
.Where(user => user.Name == "Tim Trotter")
.Single();
Запрашивание базы данных с целью
нахождения открытых дефектов,
var query = from defect in context.Defects ❹
назначенных для исправления Тиму
Код в листинге 12.1 требует определенного объема объяснений, поскольку он целиком но-
вый. Сначала создается новый контекст данных, с которым будет осуществляться работа Ê.
Контексты данных достаточно многофункциональны и отвечают за управление подключениями и
транзакциями, трансляцию запросов, отслеживание изменений в сущностях и обработку идентич-
ности. В рамках этой главы контекст данных можно рассматривать как точку соприкосновения с
базой данных. Здесь не демонстрируются более сложные средства, а будет показана только одна
удобная возможность: сообщение контексту данных о необходимости вывода на консоль всех ко-
манд SQL, которые он выполняет Ë. Все свойства, связанные с моделью, которые используются в
коде внутри этого раздела (Defects, Users и т.д.), имеют тип Таblе<Т> для соответствующего
сущностного типа. Они действуют в качестве источников данных для запросов.
Применять SampleData.Users.TesterTim для идентификации Тима в главном запросе
невозможно, т.к. этому объекту не известен идентификатор нужной строки в таблице DefectUser.
Глава 12. LINQ за рамками коллекций 365
SELECT [t0].[Summary]
FROM [dbo].[Defect] AS [t0]
WHERE ([t0].[AssignedToUserID] = @p0) AND ([t0].[Status] <> @p1)
-- @p0: Input Int32 (Size = 0; Prec = 0; Scale = 0) [2]
-- @p1: Input Int32 (Size = 0; Prec = 0; Scale = 0) [4]
Следует отметить, что первый запрос извлекает все свойства, описывающие пользователя, т.к.
заполняется целая сущность, но второй запрос извлекает только сводку, поскольку только она
и нужна. Кроме того, во втором запросе LINQ to SQL преобразует две отдельных конструкции
where в единственный фильтр в базе данных.
Средства LINQ to SQL позволяют транслировать широкий спектр выражений. Давайте слегка
усложним запрос из главы 11 и посмотрим, какой код SQL для него сгенерируется.
Следующий запрос демонстрирует, что происходит, когда с помощью конструкции let вво-
дится некий вид временной переменной. Если помните, в главе 11 мы обсуждали неестественную
ситуацию — вообразили, что вычисление длины строки занимает длительное время. И снова рас-
сматриваемое здесь выражение запроса в точности повторяет приведенное в листинге 11.11, но с
одним исключением — в нем используется источник данных. В листинге 12.2 показан код LINQ
to SQL
1
Чтобы не отвлекаться от сути запросов SQL сгенерированный дополнительный вывод, содержащий некоторые
детали контекста данных, здесь не показан. Естественно, в консольном выводе также присутствуют сводки, выводимые
циклом foreach.
Глава 12. LINQ за рамками коллекций 366
результатов. В ряде случаев поставщик мог бы за счет трюков избегать необходимости в подсчете,
просто отслеживая изменение идентификатора группы, но такой подход испытывает трудности с
некоторыми запросами. Возможно, что будущая реализация LINQ to SQL будет способна менять
образ действий в зависимости от точного запроса.
Чтобы увидеть соединение в коде SQL, записывать его явно в выражении запроса вовсе не
обязательно. Запросы, которые рассматриваются последними, продемонстрируют неявное созда-
ние соединений посредством выражений доступа к свойствам.
Давайте обратимся к простому примеру. Предположим, что необходимо вывести список дефек-
тов, отображая их сводки и названия проектов, к которым они относятся. Выражение запроса
сводится всего лишь к проецированию:
// Выражение запроса
from defect in context.Defects
select new { defect.Summary, ProjectName = defect.Project.Name }
-- Сгенерированный код SQL
SELECT [t0].[Summary], [t1].[Name]
FROM [dbo].[Defect] AS [t0]
INNER JOIN [dbo].[Project] AS [t1]
ON [t1].[ProjectID] = [t0].[ProjectID]
// Выражение запроса
from defect in context.Defects
select new { defect.Summary, Assignee = defect.AssignedTo.Name }
-- Сгенерированный код SQL
SELECT [t0].[Summary], [t1].[Name]
FROM [dbo].[Defect] AS [t0]
LEFT OUTER JOIN [dbo].[DefectUser] AS [t1]
ON [t1].[UserID] = [t0].[AssignedToUserID]
IEnumerable
Interface
_Type
IReflect
Type
ElementType Abstract Class
MemberInfo
IEnumerable<T>
GenericInterface
IEnumerable
IEnumerable
IEnumerable
Воспринимайте источник данных как простой запрос (например, SELECT * FROM SomeTable
в SQL) — вызов Where(), Select(), OrderBy() и аналогичных методов дает в результате дру-
гой запрос, основанный на первом. Имея любой запрос IQueryable, создать новый запрос можно
за счет выполнения следующих шагов.
1. Получить у существующего запроса его дерево выражения (с помощью свойства Expression).
2. Построить новое дерево выражения, которое содержит исходное выражение и нужную до-
полнительную функциональность (скажем, фильтр, проекция или упорядочение).
свои позиции, когда потребность подобного рода все же возникнет. Но, пожалуй, важнее всего
то, что вы будете понимать, что происходит, когда выдаются запросы LINQ to SQL. Большая
часть интенсивной работы поставщиков запросов производится в момент выполнения, когда они
должны проанализировать дерево выражения и преобразовать его в форму, подходящую для це-
левой платформы. Мы сосредоточимся на работе, которая выполняется перед этим — как LINQ
подготавливает запрос к выполнению.
Мы взглянем на реализации интерфейсов IQueryable и IQueryProvider, а затем попро-
буем запустить в отношении них несколько запросов. Самым интересным будет не результат, т.к.
запросы не делают чего-либо полезного, а последовательность вызовов, совершенных вплоть до
данной точки выполнения. Мы уделим основное внимание двум типам: FakeQueryProvider и
FakeQuery. В реализации каждого метода интерфейса на консоль выводится текущее задейство-
ванное выражение с применением простого метода регистрации (здесь не показанного).
В листинге 12.3 представлен код класса FakeQuery.
Листинг 12.3. Простая реализация интерфейса IQueryable, регистрирующая
вызовы методов
свойства Туре конкретного выражения. Перегруженные версии метода Execute() после реги-
страции вызова возвращают пустые результаты. Именно в них обычно проводится интенсивный
анализ наряду с действительным обращением к веб-службе, базе данных или другой целевой
платформе.
Хотя никакой реальной работы не было сделано, интересные вещи начинают происходить, когда
класс FakeQuery применяется в качестве источника в выражении запроса. Я уже упоминал о
наличии возможности писать выражения запросов без явной реализации методов для обработки
стандартных операций запросов: имеются в виду расширяющие методы — на этот раз из класса
Queryable.
Рис. 12.3. Запрос выбирает один из двух путей в зависимости от того, какие интерфейсы реализует
источник данных — IQueryable или только IEnumerable
трасту с этим реализации операций запросов в Queryable делают немногое: они лишь создают
новый запрос на основе параметров или вызывают метод Execute() поставщика запросов, как
было описано в конце раздела 12.2.1. Другими словами, эти расширяющие методы применяются
только для построения запросов и требования их выполнения — они не содержат логику, лежащую
в основе операций. Это означает, что такие расширяющие методы подходят для любого поставщи-
ка LINQ который использует деревья выражений, но сами по себе они бесполезны. Они являются
связующими элементами между вашим кодом и деталями поставщика.
Имея доступные расширяющие методы Queryable, а также готовые к применению реализации
IQueryable и IQueryProvider, можно, наконец, посмотреть на то, что происходит, когда
выражение запроса используется со специальным поставщиком.
Какими, по вашему мнению, должны быть результаты запуска кода из листинга 12.5? В частно-
сти, что должно быть зарегистрировано последним, в точке, когда обычно ожидается выполнение
какой-то реальной работы с деревом выражения? Ниже показаны результаты, слегка переформа-
тированные для большей наглядности:
FakeQueryProvider.CreateQuery
Expression=FakeQuery.Where(х => x.StartsWith("abc"))
FakeQueryProvider.CreateQuery
Expression=FakeQuery.Where(x => x.StartsWith("abc"))
.Select(x => x.Length)
FakeQuery<Int32>.GetEnumerator
Expression=FakeQuery.Where(x => x.StartsWith("abc"))
.Select(x => x.Length)
Здесь следует отметить два важных момента: метод GetEnumerator() вызывается только в
конце, а не на промежуточных запросах; ко времени вызова GetEnumerator() в наличии вся
информация, представленная в исходном выражении запроса. Вы не обязаны вручную отслеживать
начальные части выражения на каждом шаге — единственное дерево выражения содержит всю
необходимую информацию.
Кстати, не позволяйте столь лаконичному выводу вводить вас в заблуждение — действитель-
ное дерево выражения будет глубоким и сложным, особенно из-за конструкции where, включа-
ющей дополнительный вызов метода. Такое дерево выражения является именно тем, что LINQ
to SQL будет исследовать с целью выяснения, какой запрос выполнять. Поставщики LINQ мог-
ли бы строить собственные запросы (в любой необходимой им форме), когда совершены вы-
зовы CreateQuery(), но обычно проще просматривать финальное дерево, когда вызывается
GetEnumerator(), поскольку вся нужная информация доступна в одном месте.
Последним вызовом, зарегистрированным запросом из листинга 12.5, был FakeQuery.Get
Enumerator(), и вас может заинтересовать, почему в IQueryProvider также необходим ме-
тод Execute(). Дело в том, что не все выражения запросов генерируют последовательности.
Если вы применяете операцию агрегирования, такую как Sum(), Count() или Average(), то
в действительности больше не создаете источник, а немедленно вычисляете результат. Именно
тогда и вызывается метод Execute(), как продемонстрирует вывод запроса из листинга 12.6.
// Вывод
FakeQueryProvider.CreateQuery
Expression=FakeQuery.Where(x => x.StartsWith("abc"))
FakeQueryProvider.CreateQuery
Expression=FakeQuery.Where(x => x.StartsWith("abc"))
.Select(x => x.Length)
FakeQueryProvider.Execute
Expression=FakeQuery.Where(x => x.StartsWith("abc"))
.Select(x => x.Length)
.Average()
Класс FakeQueryProvider может оказаться довольно удобным, когда нужно попять, что ком-
пилятор C# делает “за кулисами” с выражениями запросов. Он отобразит прозрачные идентифи-
каторы, введенные внутри выражения запроса, транслированные вызовы методов SelectMany(),
GroupJoin() и т.д.
IEquatable<XName>
IXmlLineInfo
ISerializable
XName XObject XNamespace
Sealed Class Abstract Class Sealed Class
XNode XAttribute
Abstract Class Class
XObject XObject
XContainer XText
Abstract Class Class
XNode XNode
IXmlSerializable
XDocument XElement
Class Class
XContainer XContainer
Рис. 12.4. Диаграмма классов для UNQ to XML, отражающая наиболее часто используемые типы
• Тип XNamespace представляет пространство имен XML — в сущности, URI- указатель. Его
экземпляры обычно создаются посредством неявного преобразования из строки.
• Тип XObject — это общий предок для XNode и XAttribute; в отличие от API-интерфейса
DOM, в LINQ to XML атрибут не является узлом. Например, методы, возвращающие дочер-
ние узлы, не включают атрибуты.
4
Я регулярно забываю, как выглядит название этого пространства имен — System.Xml.Linq или
System.Linq.Xml. Если вы запомните, что в первую очередь он является API-интерфейсом для XML, то все
должно быть в порядке.
Глава 12. LINQ за рамками коллекций 378
• Тип XNode представляет узел в дереве XML В нем определены разнообразные члены, пред-
назначенные для манипулирования и выдачи запросов к дереву. Доступно несколько дру-
гих производных от XNode классов, которые не были показаны на рис. 12.4, наподобие
XComment и XDeclaration. Они применяются относительно редко — самыми распростра-
ненными типами узлов являются документы, элементы и текст.
• Тип XAttribute — это атрибут с именем и значением. Значением, по сути, является текст,
но предусмотрены явные преобразования во множество других типов, таких как int и
DateTime.
• Тип XContainer представляет узел в дереве XML, который может иметь дочернее содер-
жимое — по существу это элемент или документ.
• Тип XText — это текстовый узел, и для представления текстовых узлов CDATA использует-
ся дополнительный производный тип XCData. (Узел CDATA является грубым эквивалентом
дословного строкового литерала, в котором требуется меньшее количество отмен.) Экзем-
пляры типа XText редко создаются напрямую в пользовательском коде; вместо этого, когда
в качестве содержимого элемента или документа используется строка, она преобразуется в
экземпляр XText.
• Тип XDocument — это документ. Доступ к его корневому элементу осуществляется с исполь-
зованием свойства Root, которое является эквивалентом XmlDocument. DocumentElement.
Как отмечалось ранее, часто документ не требуется.
В рамках документной модели доступно даже большее число типов, и существует несколько
других типов для таких возможностей, как опции загрузки и сохранения, но этот список отражает
только самые важные средства. Из перечисленных выше типов придется регулярно явно ссылать-
ся только на XElement и XAttribute. Если вы работаете с пространствами имен, то будете
также использовать XNamespace, но в основном большинство оставшихся типов можно проигно-
рировать. Просто удивительно, насколько много можно делать с таким небольшим количеством
типов.
Раз уж речь зашла об удивительном, не могу удержаться, чтобы не продемонстрировать вам,
как работает поддержка пространств имен в LINQ to XML. Мы не собираемся применять про-
странства имен где-либо еще, но это удачный пример того, как правильно спроектированный набор
преобразований может значительно упростить дело. Это также облегчит освоение следующей те-
мы: конструирование элементов.
Если необходимо только указать имя элемента или атрибута без пространства имен, можно
использовать строку. Тем не менее, вы нс обнаружите в каких-либо типах конструкторы с пара-
метрами string — все они принимают XName. Существует неявное пре образование из string
в XName, а также из string в XNamespace. Объединение пространства имен и строки также
дает XName. Граница между некорректным и удачным применением операции чрезвычайно тонка,
но в данном случае LINQ to XML действительно воплощает ее в жизнь. Ниже показан код для
создания двух элементов — одного внутри пространства имен, а другого нет:
XElement noNamespace = new XElement("no-namespace");
XNamespace ns = "http://csharpindepth.com/sample/namespace";
XElement withNamespace = new XElement(ns + "in-namespace");
Глава 12. LINQ за рамками коллекций 379
Это делает код более читабельным, даже когда задействованы пространства имен, что посту-
пило как долгожданное облегчение из ряда других API-интерфейсов. Но мы лишь создали два
пустых элемента. Как предоставить им какое-то содержимое?
• Строки, числа, даты, время и тому подобные значения добавляются за счет их преобразова-
ния в узлы XText с применением стандартного форматирования XML.
Это означает, что содержимое часто не приходится специальным образом готовить, прежде
чем его можно будет добавить в элемент — все необходимое сделает LINQ to XML. Детали
явно документированы, поэтому переживать о том, что это сверхъестественно, не нужно — все
действительно работает.
Конструирование вложенных элементов приводит к коду, который естественным образом вос-
производит иерархическую структуру дерева. Это лучше продемонстрировать на примере. Ниже
показан фрагмент кода LINQ to XML:
new XElement("root",
new XElement("child",
5
В некотором смысле неприятно, что тип XElement не реализует интерфейс IEnumerable, поскольку в против-
ном случае возможен был бы еще один подход к конструированию, предусматривающий использование инициализа-
торов коллекций. Тем не менее, конструкторы и без этот работают довольно искусно.
Глава 12. LINQ за рамками коллекций 380
А вот разметка XML созданного элемента — обратите внимание на визуальное сходство между
кодом и выводом:
<root>
<child>
<grandchild>text</grandchild>
</child>
<other-child />
</root>
Пока все идет хорошо, но в приведенном выше списке важной частью является четвертый
пункт, упоминающий о рекурсивной обработке, поскольку это позволяет строить структуру XML
из запроса LINQ естественным образом. Например, на веб-сайте книги доступен код для ге-
нерации RSS-ленты из базы данных. Оператор для конструирования XML-документа занимает
28 строк, что обычно я счел бы отвратительным, однако он удивительно удобен для чтения6 .
Этот оператор содержит два запроса LINQ — один для заполнения значения атрибута и один
для предоставления последовательности элементов, каждый из которых представляет элемент но-
востей. Читая такой код, становится очевидным, как будет выглядеть результирующая разметка
XML.
Для большей конкретики давайте возьмем два простых примера из системы отслеживания
дефектов. При этом будет применяться образец данных LINQ to Objects, но вы можете вос-
пользоваться почти идентичными запросами для работы с другим поставщиком LINQ. Сначала
необходимо построить элемент, содержащий всех пользователей в системе. Для этого понадобит-
ся просто проекция, потому в листинге 12.7 применяется точечная нотация.
Если вы хотите сделать несколько более сложный запрос, возможно, стоит воспользоваться
выражением запроса. В листинге 12.8 создается еще один список пользователей, но на этот раз
он включает только разработчиков из SkeetySoft. Ради небольшого разнообразия имя каждого
разработчика делается текстовым узлом внутри элемента, а не значением атрибута.
Подобный прием может быть применен ко всем данным в образце, давая в результате документ
следующего вида:
<defect-system>
<projects>
<project name="..." id="...">
<subscription email="..." />
</project>
</projects>
<users>
<user name="..." id="..." type="..." />
</users>
<defects>
<defect id="..." summary="..." created="..." project="..."
assigned-to="..." created-by="..." status="..."
severity="..." last-modified="..." />
</defects>
</defect-system>
Код для генерации всего этого находится в файле XmlSampleData.cs внутри загружаемого
решения. Он демонстрирует альтернативу подходу с одним крупным оператором: каждый элемент,
находящийся ниже верхнего уровня, создается отдельно, а затем они компонуются примерно так:
Мы будем использовать эту разметку XML при иллюстрации следующего средства интеграции
LINQ: запросов. Давайте начнем с методов запросов, доступных для одиночного узла.
Глава 12. LINQ за рамками коллекций 382
• Ancestors
• AncestorsAndSelf
• Annotations
• Attributes
• Descendants
• DescendantsAndSelf
• DescendantNodes
• DescendantNodesAndSelf
• Elements
• ElementsAfterSelf
• ElementsBeforeSelf
• Nodes
Все методы не требуют особых объяснений (дополнительные сведения можно найти в доку-
ментации MSDN). Существуют удобные перегруженные версии, предназначенные для извлечения
только узлов с указанным именем; к примеру, вызов Descendants("user") на экземпляре
XElement возвратит все элементы user, расположенные ниже элемента, на котором был произ-
веден вызов.
В дополнение к методам, возвращающим последовательности, некоторые методы возвращают
одиночный результат — наиболее важными являются Attribute() и Element(), возвращаю-
щие, соответственно, именованный атрибут и первый дочернин элемент с указанным именем.
Кроме того, доступны явные преобразования из XAttribute или XElement в другие типы,
такие как int, string и DateTime. Это важно для результатов фильтрации и проецирования.
Каждое преобразование в тип значения, не допускающий null, также имеет преобразование в
его эквивалент, значение null допускающий; такие преобразования (и преобразование в string)
возвращают значение null, если вызываются на ссылке null. Подобное распространение null
означает, что вы не обязаны проверять наличие или отсутствие атрибутов или элементов внутри
запроса — вместо этого вы можете применять результаты запроса.
Как это касается LINQ? Действительно, тот факт, что множественные результаты поиска
возвращаются в виде IEnumerable<T>, означает возможность использования обычных мето-
дов LINQ to Objects после нахождения некоторых элементов. В листинге 12.9 приведен пример
поиска имен и типов для пользователей, на этот раз в образце данных XML.
Глава 12. LINQ за рамками коллекций 383
Поскольку внутри проекта нет никаких других элементов кроме subscription, можно было
бы воспользоваться перегруженной версией метода Elements(), чтобы не указывать имя. Лично
я считаю указание имени элемента в данном случае более ясным, но это дело вкуса. (Надо сказать,
что аналогичный довод можно было бы привести и в пользу вызова Element("projects")
.Elements("project").)
Ниже показан тот же запрос, записанный с применением точечной нотации и перегруженной
версии метода SelectMany(), которая только возвращает выровненную последовательность, не
выполняя никакого дальнейшего проецирования:
root.Element("projects").Elements()
.SelectMany(project => project.Elements("subscription"))
Ни один из этих запросов нельзя назвать полностью нечитабельным, однако они и не идеаль-
ны. В LINQ to XML предлагается несколько расширяющих методов (в классе System.Xml.Linq
.Extensions), которые либо действуют на специфическом типе последовательности, либо явля-
ется обобщенным с ограниченным аргументом типа, чтобы справиться с отсутствием ковариант-
ности обобщенных интерфейсов в версиях, предшествующих C# 4. Имеется метод InDocument
Order(), возвращающий все узлы в порядке, заданном в документе, и большинство осевых ме-
тодов, упомянутых в разделе 12.4.3, также доступны в виде расширяющих методов. Это означает,
что предыдущий запрос можно преобразовать в следующую более простую форму:
root.Element("projects").Elements().Elements("subscription")
Конструкция такого вида упрощает запись XPath-подобных запросов в LINQ to XML без
требования, чтобы все было строками. Если вы хотите использовать язык XPath, он доступен
через дополнительные расширяющие методы, но методы запросов чаще меня устраивали, чем
не устраивали. Кроме того, допускается смешивать осевые методы и операции LINQ to Objects.
Например, чтобы найти все подписки на уведомления для проектов с названием, включающем
строку Media, можно было бы записать так:
root.Element("projects").Elements()
.Where(project => ((string) project.Attribute("name"))
.Contains("Media"))
.Elements("subscription")
Прежде чем переходить к Parallel LINQ давайте подумаем о том, как проект LINQ to XML за-
служил указания части “LINQ” в своем названии, и каким образом можно было бы потенциально
применить те же самые приемы к собственным API-интерфейсам.
• Она возвращает последовательности из своих методов запросов. Пожалуй, это наиболее оче-
видный шаг, который API-интерфейсы доступа к данным должны были бы уже предпри-
нимать: возвращение результатов запроса в виде IEnumerable<Т> или реализующего его
класса — далеко не бином Ньютона.
Вы можете придумать и другие пути взаимодействия ваших библиотек с LINQ: это не един-
ственные варианты, которые вы должны рассмотреть, но они являются хорошей отправной точкой.
Прежде всего, я настоятельно рекомендую поставить себя на место разработчика, желающего
использовать ваш API-интерфейс внутри кода, в котором уже применяется LINQ. К чему такой
разработчик может стремиться? Можно ли легко смешивать в коде взаимодействие с LINQ и
вашим API-интерфейсом, или же они действительно предназначены для разных целей?
Мы находимся примерно на середине пути скоростного обзора разнообразных подходов, обеспе-
чиваемых LINQ. Наша следующая остановка в некоторой степени обнадеживает, но в некоторой —
устрашает: мы снова возвращаемся к запрашиванию простых последовательностей, но на этот раз
параллельно. . .
• Для каждого пикселя в изображении будет вычисляться байтовое значение, которое пред-
ставляет собой индекс внутри палитры из 256 записей.
Последний аспект из перечисленных критически важен — он означает, что эта задача совер-
шенно параллельна. Другими словами, в самой задаче нет ничего такого, что затруднило бы ее
распараллеливание. По-прежнему необходим механизм для распределения рабочей нагрузки по
потокам с последующим сбором результатов, но остальное должно быть простым. Ответственным
за распределение и сбор будет PLINQ (с небольшой помощью ему); вам всего лишь понадобится
выразить диапазон пикселей и способ вычисления цвета каждого пикселя.
В целях демонстрации нескольких подходов я построил абстрактный базовый класс, который
отвечает за надлежащую настройку, запуск запроса и отображение результатов; он также имеет
метод для вычисления цвета отдельного пикселя. Абстрактный метод должен создавать байтовый
массив значений, которые затем преобразуются в изображение. Сначала идет первая строка пиксе-
лей, слева направо, потом вторая строка и т.д. Каждый приводимый здесь пример сводится просто
к реализации этого метода.
Должен заметить, что применение в такой ситуации LINQ на самом деле не является идеаль-
ным решением — данный подход по разным причинам неэффективен. Не заостряйте внимания на
этой стороне дела: сосредоточьтесь на идее того, что мы имеем совершенно параллельный запрос,
и хотим выполнить его на нескольких ядрах.
В листинге 12.10 показана однопоточная версия метода во всей своей изящной простоте.
Запрос проходит по всем строкам и позициям в каждой строке, вычисляя индекс соответству-
ющего пикселя. Вызов ТоАrrау() вычисляет результирующую последовательность, преобразуя
ее в массив. На рис. 12.5 можно видеть симпатичные результаты.
На моем стареньком двухядерном лаптопе генерация заняла около 5,5 секунды. Метод Compute
Index() выполняет больше итераций, чем действительно необходимо, но это позволяет сделать
разницу в оценках времени более очевидной7 . Теперь, когда имеется эталон в терминах измерения
времени и внешнего вида результатов, можно заняться распараллеливанием запроса.
7
Надлежащее эталонное тестирование выполнять нелегко, особенно в условиях многопоточности. Я не пытался
здесь проводить точные измерения. Приводимые оценки времени предназначены просто для отражения факта более
быстрого или медленного выполнения; относитесь к этим числам скептически.
Глава 12. LINQ за рамками коллекций 387
IEnumerable
ParallelQuery
Class
IEnumerable<TSource>
IEnumerable
ParallelQuery<TSource>
Generic Class
ParallelQuery
OrderedParallelQuery<TSource>
Generic Class
ParallelQuery<TSource>
Рис. 12.6. Диаграмма классов для Parallel LINQ, включая отношения с обычными интерфейсами LINQ
Важный момент заключается в том, что помимо упорядочения эти методы не должны влиять
на результаты запроса. Вы можете спроектировать свой запрос и протестировать его в LINQ to
Objects, затем распараллелить, сформулировать требования к упорядочению и при необходимости
подстроить запрос, чтобы он делал именно то, что нужно. Если вы покажете окончательный за-
прос кому-то, кто знает LINQ, но не PLINQ, то вам придется объяснить только вызовы методов,
специфичных для PLINQ, а остальные части запроса должны быть ясны. Приходилось ли вам ви-
деть настолько простой путь обеспечения параллелизма? (Остальные аспекты Parallel Extensions
также направлены на достижение простоты, где только это возможно.)
GetEnumerator() Subscribe(observer)
Возвращает: true
Current
...(дополнительные элементы)...
MoveNext() OnCompleted()
Возвращает: false
В данной ситуации трудно представить себе, как можно было бы получить ошибку, однако
ради полноты делегат для уведомления об ошибке все же включен. Результаты вполне ожидаемы:
Received 0
Received 1
...
Received 9
Finished
Фильтрация и проецирование
В целях простоты здесь не добавлялись обработчики для завершения или ошибки, а приме-
нение преобразования из группы методов Console.WriteLine() в Action<int> позволило
сохранить код изящным и кратким. Запрос выдает те же результаты, как если это было в LINQ
to Objects: 0, 4, 16 и т.д. А теперь перейдем к группированию.
Группирование
Пожалуй, понять этот запрос будет проще, если вспомнить, что работа с группами в LINQ
to Objects часто предусматривает наличие вложенного цикла foreach — вот так и LINQ to Rx
присутствуют вложенные подписки.
Глава 12. LINQ за рамками коллекций 395
Value: 0; Group: 0
Value: 1; Group: 1
Value: 2; Group: 2
Value: 3; Group: 0
Value: 4; Group: 1
Value: 5; Group: 2
Value: 6; Group: 0
Value: 7; Group: 1
Value: 8; Group: 2
Value: 9; Group: 0
Это обретает особый смысл при обдумывании в терминах модели с активным источником, а в
ряде случаев означает, что операции, которые требовали бы буферизации значительного объема
данных в LINQ to Objects, могут быть реализованы в LINQ to Rx намного более эффективно.
В качестве последнего примера рассмотрим еще одну операцию, имеющую дело с несколькими
последовательностями.
Выравнивание
{ x = 1, у = 1 }
{ х = 2, у = 1 }
{ х = 2, у = 2 }
{ х = 3, у = 1 }
{ х = 3, у = 2 }
{ х = 3, у = 3 }
Вы уже знаете, что конструкция let работает, просто вызывая метод Select(), что есте-
ственным образом вписывается в LINQ to Rx, но не все операции LINQ to Objects реализованы
в LINQ to Rx. Как правило, отсутствуют те операции, которые приводили бы к буферизации
своего вывода и возвращению нового наблюдаемого объекта. Например, не существует методов
Reverse() и OrderBy(). Для языка C# это вполне нормально — он всего лишь не позволяет
использовать конструкцию orderby в выражении запроса, основанном на наблюдаемых объектах.
Доступен метод Join(), однако он не имеет дело с наблюдаемыми объектами напрямую — он
обрабатывает планы соединений.
Это часть реализации Rx исчисления соединений, и она выходит далеко за рамки материала
настоящей книги. Подобным же образом отсутствует метод GroupJoin(), поэтому операция
join...into не поддерживается.
Описание различных стандартных операций запросов LINQ которые не покрыты синтаксисом
выражений запросов, а также широкого спектра дополнительных методов, делающих их доступны-
ми, ищите в документации по пространству имен System.Reactive. Хотя вас может несколько
разочаровать отсутствие в LINQ to Rx знакомой функциональности из LINQ to Objects (в боль-
шинстве случаев из-за того, что она не имеет здесь смысла), вы удивитесь, насколько в действи-
тельности богат набор доступных методов. Многие новые методы впоследствии были перенесены
в LINQ to Objects и находятся в сборке System.Interactive.
что язык указывает трансляции запросов в терминах шаблона, который должен поддерживаться
настолько, насколько это имеет смысл для заданного поставщика. Надеюсь, вы поняли, что хотя
работа с моделями с активным и пассивным источниками данных совершенно отличается, LINQ
действует в качестве объединяющей силы там, где это возможно.
Возможно, вас обрадует, что последняя тема в данной главе будет намного проще — мы возвра-
тимся обратно к LINQ to Objects, но на этот раз займемся написанием собственных расширяющих
методов.
Модульные тесты
Как правило, написать для операций качественный набор модульных тестов довольно легко, хо-
тя вы можете быть удивлены тем, сколько их понадобится для такого, на первый взгляд, простого
кода. Не забывайте тестировать краевые случаи, такие как пустые последовательности и недо-
пустимые аргументы. В проекте модульного тестирования в рамках MoreLINQ (http://code.
google.com/p/morelinq/) имеются вспомогательные методы, которые, возможно, вы решите
задействовать в своих тестах.
Проверка аргументов
Хорошие методы проверяют свои аргументы, но возникает проблема, когда дело доходит до
операций LINQ. Как вы уже видели, многие операции возвращают еще одну последовательность,
и простейшим способом реализовать такую функциональность являются итераторные блоки. Но
в действительности проверка аргументов должна выполняться при вызове метода, не дожидаясь,
пока вызывающий код решит начать проход по результатам. Если вы собираетесь использовать
итераторный блок, разбейте свой метод на два: проводите проверку аргументов в открытом мето-
де, а затем вызывайте закрытый метод для выполнения итерации.
Глава 12. LINQ за рамками коллекций 398
Оптимизация
Документация
Важно документировать то, что ваш код делает с входными данными, и также ожидаемую
производительность операции. Это особенно важно, если метод должен работать с несколькими
последовательностями: какая из них будет обработана первой и насколько? Должен ли код орга-
низовать поток для данных, буферизировать их или совмещать то и другое? Какое выполнение
применяется — отложенное или немедленное? Могут ли какие-то параметры принимать значение
null, и если так, то имеет ли это особый смысл?
Многие операции LINQ имеют перегруженные версии, которые позволяют указывать подхо-
дящую реализацию IEqualityComparer<T> или IComparer<T>. Если вы строите библиотеку
универсального назначения для других (потенциально для контактирующих с вами разработчи-
ков), может оказаться полезным предоставление аналогичных перегруженных версий. С другой
Глава 12. LINQ за рамками коллекций 399
}
using (IEnumerator<T> iterator = source.GetEnumerator()) ❸Обработка
медленного случая
{
if (!iterator.MoveNext())
{
throw new InvalidOperationException("Sequence was empty.");
}
int countSoFar = 1;
Глава 12. LINQ за рамками коллекций 400
T current = iterator.Current;
while (iterator.MoveNext())
{
countSoFar++;
if (random.Next(countSoFar) == 0) ❹ Замена текущего предположения
с подходящей вероятностью
{
current = iterator.Current;
}
}
return current;
}
}
12.7 Резюме
Уф! Эта глава оказалась полной противоположностью большинству других глав книги. Вместо
подробного рассмотрения какой-то одной темы был раскрыт целый ряд технологий LINQ, но на
поверхностном уровне.
9
Загружаемый код содержит одну и ту же проверку для реализаций ICollection<T>, как это делает метол
Count() в .NET 4. Это в точности тот же самый блок кода, просто с другим типом и именем переменной.
10
Я спокойно заявляю, что это умелый прием, поскольку это не моя идея, хотя и моя реализация.
Глава 12. LINQ за рамками коллекций 401
В этой главе...
• Необязательные параметры
• Именованные аргументы
• Модернизация параметров ref в СОМ
• Встраивание основных сборок, взаимодействия с СОМ
• Вызов именованных индексаторов, объявленных в СОМ
• Обобщенная вариантность для интерфейсов и делегатов
• Изменения в блокировке и события, подобные полям
жать их отдельно, чтобы с ними можно было ознакомиться по очереди, а затем эти средства будут
использоваться вместе в более интересных примерах.
Параметры и аргументы
В этом разделе вполне очевидно много раз будут упоминаться параметры и аргументы. В неофи-
циальной беседе эти два термина часто применяются взаимозаменяемо, но я буду использовать их
согласно формальным определениям. Просто в качестве напоминания: параметр (также известный
как формальный параметр) — это переменная, которая является частью объявления метода или
индексатора. Аргумент — это выражение, применяемое при вызове метода или индексатора. Для
примера взгляните на следующий фрагмент кода:
Мотивация
Необязательные параметры обычно используются, когда для операции должно быть указано
множество значений, и на протяжении длительного времени применяются одни и те же значе-
ния. Например, предположим, что необходимо прочитать текстовый файл; может понадобиться
предоставить метод, который позволяет вызывающему коду задавать имя файла и используемую
кодировку. Однако кодировкой почти всегда является UTF-8, так что было бы неплохо иметь воз-
можность применять ее автоматически, если именно она и нужна. Исторически сложилось так, что
идиоматическим подходом для достижения этой цели в C# была перегрузка метода: объявление
одного метода со всеми возможными параметрами и других методов, которые вызывают первый
метод, передавая ему подходящие стандартные значения.
Например, методы могут быть созданы следующим образом:
Глава 13. Небольшие изменения, направленные на упрощение кода 405
Это хорошо работает для одного параметра, но становится сложным, когда вариантов много,
т.к. дополнительный вариант удваивает количество возможных перегруженных версий. Если два
параметра имеют один и тот же тип, такой подход естественным образом приводит к нескольким
методам с одинаковыми сигнатурами, что недопустимо. Часто один набор перегруженных вер-
сий также требуется для нескольких типов параметров. Например, метод XmlReader.Create()
может создавать объект XmlReader из экземпляра Stream, TextReader или string, но он
также предоставляет возможность указания XmlReaderSettings и других аргументов. Из-за
такого дублирования для метода предусмотрено 12 перегруженных версий.
За счет использования необязательных параметров их количество можно было бы значительно
сократить. Давайте посмотрим, как это сделать.
Чтобы сделать параметр необязательным, нужно просто предоставить для него стандартное
значение, что похоже на инициализатор переменной. На рис. 13.1 показан метод с тремя парамет-
рами: два необязательных и один обязательный.
Обязательный Необязательные
параметр параметры
Стандартные значения
Этот метод всего лишь выводит значения аргументов на консоль, но этого вполне достаточно
для оценки происходящего. В листинге 13.1 приведен код с объявлением метода и его тремя
вызовами, в каждом из которых указывается разное количество аргументов
Объявление Примечания
Foo(int х, int y = 10) Для стандартного значения используется
числовой литерал
Для противовеса в табл. 13.2 показано несколько недопустимых списков параметров с объяс-
нениями, почему они не разрешены.
Тот факт, что стандартное значение должно быть константой, является недостатком в двух
разных ситуациях. Одна из них знакома в несколько ином контексте, который мы сейчас и рас-
смотрим.
Глава 13. Небольшие изменения, направленные на упрощение кода 408
5. Снова запустите приложение — оно по-прежнему выведет на консоль значение 10. Дело в
том, что это значение было скомпилировано прямо в исполняемый файл.
6. Перекомпилируйте приложение и запустите его — на этот раз оно выведет па консоль зна-
чение 20.
Такая проблема с версиями может привести к ошибкам, которые трудно отслеживать, посколь-
ку весь код выглядит корректным. По существу вы ограничены применением подлинных констант,
которые никогда не должны изменяться, выступая в качестве стандартных значений для необяза-
тельных параметров3 . Эта система обладает одним преимуществом: она дает вызывающему коду
3
Или можно было бы просто согласиться, что при изменении значения необходимо перекомпилировать весь имею-
щийся код. Во многих контекстах это разумный компромисс.
Глава 13. Небольшие изменения, направленные на упрощение кода 409
гарантию того, что значение, которое ему известно на этапе компиляции, и является тем значе-
нием, которое будет использоваться во время выполнения. Разработчики могут чувствовать себя
более комфортно с этим, чем с динамически вычисляемым значением либо тем, которое зависит
от версии библиотеки, применяемой во время выполнения.
Конечно, это также означает невозможность использовать значения, которые нельзя выразить
в виде констант. Например, вы не можете создать метод со стандартным значением вроде “теку-
щего времени”.
К счастью, существует способ обойти ограничение, касающееся того, что стандартные значения
должны быть константами. По сути, для представления стандартного значения вы вводите “ма-
гическое” значение и затем внутри самого метода заменяете его действительным стандартным
значением. Если понятие “магическое” значение вызывает у вас беспокойство, я не удивлен, но
для этого “магического” значения мы будем применять null, которое уже представляет отсутствие
нормального значения. Если типом параметра обычно был бы тип значения, мы просто назначаем
ему соответствующий тип значения, допускающий null, после чего можно будет по-прежнему
указывать, что стандартным значением является null.
В качестве примера давайте рассмотрим ситуацию, похожую на ту, что я использовал при вве-
дении во всю тему: позволим вызывающему коду передавать методу нужную кодировку текста, но
установим для нее стандартное значение UTF-8. Вы не можете указать стандартную кодировку как
Encoding.UTF8, потому что это не константное значение, но можете трактовать значение null
параметра как “применить стандартное значение”. Для демонстрации обработки типов значений
создадим метод, добавляющий метку времени к текстовому файлу с сообщением. Мы установим
для кодировки стандартное значение UTF-8, а для метки времени — текущее время. В листинге
13.2 приведен полный исходный код и несколько примеров использования метода.
Листинг 13.2. Применение стандартных значений null для обработки неконстантных ситуа-
ций
string message,
Encoding encoding = null ❶ Два необязательных параметра
DateTime? timestamp = null)
{
Encoding realEncoding = encoding ?? Encoding.UTF8; ❷ Применение
операции
объединения с null
для удобства
4
Нам почти наверняка понадобится второе специальное значение, подобное null, которое имеет следующий
смысл: “использовать для этого параметра стандартное значение”. Затем можно было бы позволить этому специ-
альному значению либо передаваться автоматически для пропущенных аргументов, либо явно указываться в списке
аргументов. Уверен, что это привело бы к десяткам проблем, но поэкспериментировать было бы интересно.
Глава 13. Небольшие изменения, направленные на упрощение кода 411
Я не сомневаюсь, что вам приходилось видеть код, который выглядит примерно так:
Выбранный пример довольно скучный, но все может стать намного хуже при большем ко-
личестве аргументов, особенно если многие из них имеют тот же самый тип. Тем не менее, он
по-прежнему реалистичен; даже при наличии всего лишь двух параметров мне было бы трудно по-
нять смысл каждого аргумента, если бы не присутствующие комментарии. Тем не менее, проблема
остается: комментарии могут сообщать некорректные сведения о коде, который они описывают.
Не существует средства для их проверки. В противоположность этому, именованные аргументы
запрашивают помощь у компилятора.
Синтаксис
Все, что понадобится сделать для прояснения кода в предыдущем примере — снабдить каждый
аргумент префиксом в форме имени соответствующего параметра и двоеточием:
MessageBox.Show(caption: "Ouch!",
text: "Please do not press this button again");
Текст по-прежнему будет находиться в правильных местах диалогового окна, т.к. компилятор
выяснит, что вы имели в виду, на основе имен.
Глава 13. Небольшие изменения, направленные на упрощение кода 412
В качестве еще одного примера взгляните на вызов конструктора типа StreamWriter в ли-
стинге 13.2. Во втором аргументе задано просто true — что оно означает? Поток должен при-
нудительно сбрасываться после каждой записи? Включить маркер порядка следования байтов?
Дополнять существующий файл вместо создания нового? Ниже представлен эквивалентный вы-
зов, в котором применяются именованные аргументы:
Если необходимо указать имя аргумента для параметра ref или out, модификатор ref или out
помещается после имени и перед аргументом. Используя метод int.TryParse() в качестве
примера, можно написать следующий код:
int number;
bool success = int.TryParse("10", result: out number);
Для исследования ряда других аспектов синтаксиса в листинге 13.3 показан метод с тремя
целочисленными параметрами, подобный тому, что применялся в начале описания необязательных
параметров.
{
Console.WriteLine("x={0} у={1} z={2}", х, у, z);
}
...
Dump(1, 2, 3); ❷ Вызов метода как обычно
Dump(z: 3, y: 2, x: 1);
Dump(1, у: 2, z: 3); ❹ Указание имен для некоторых аргументов
Dump(1, z: 3, y: 2);
Все вызовы в листинге 13.3 дают один и тот же вывод: х=1, у=2, z=3. Фактически в коде
осуществляется обращение к одному методу пятью разными способами. Полезно отметить, что
никаких трюков при объявлении метода не предпринималось Ê; именованные аргументы можно
применять с любым методом, принимающим параметры. Сначала метод вызывается как обычно,
Глава 13. Небольшие изменения, направленные на упрощение кода 413
без использования новых средств Ë. Это своего рода контрольная точка, позволяющая удостове-
риться, что все остальные вызовы на самом деле эквивалентны. Затем делаются два обращения к
методу с применением только именованных аргументов Ì. Во втором вызове порядок следования
аргументов изменен на обратный, но результат по-прежнему тот же самый, поскольку аргументы
сопоставляются с параметрами по именам, а не позициям. Наконец, в последних двух вызовах
используется смесь из именованных и позиционных аргументов Í. Позиционный аргумент — это
такой, для которого не указано имя, поэтому любой аргумент в допустимом коде C# 3 с точки
зрения C# 4 является позиционным.
На рис. 13.2 показано, как работает последняя строка кода.
Dump(1, z: 3, y: 2)
Позиционный Именованные
аргумент аргументы
(который здесь не показан, т.к. он не менялся). В листинге 13.4 представлены два вызова Dump(),
которые дают в результате отличающийся вывод.
Log: 1
Log: 2
Log: 3
х=1 у=2 z=3
Log: 3
Log: 1
Log: 2
x=1 y=2 z=3
int i = 0;
Dump(x: ++i, у: ++i, z: ++i);
i = 0;
Dump(z: ++i, x: ++i, y: ++i);
Результатом кода из листинга 13.5 может быть кровавая сцена убийства, когда кто- то, кому
доведется сопровождать такой код, будет испытывать желание последовать за его автором с топо-
ром. Да, говоря формально, последняя строка приводит к получению х=2 у=3 z=1, но я уверен,
вы понимаете, что я имел в виду. Просто скажем “нет” коду подобного рода. Разумеется, необходи-
мо упорядочивать свои аргументы для достижения лучшей читабельности. Например, вы можете
решить, что оформление вызова MessageBox.Show() с расположением заголовка над текстом в
Глава 13. Небольшие изменения, направленные на упрощение кода 415
самом коде более точно отражает компоновку на экране. Тем не менее, если вы хотите опираться
на специфичный порядок оценки аргументов, предусмотрите несколько локальных переменных,
чтобы подходящий код выполнялся в отдельных операторах. Компилятор на это не обратит вни-
мания, поскольку он будет следовать правилам спецификации, но такой прием позволяет снизить
риск “безобидного рефакторинга”, который неумышленно вводит тонкую ошибку.
Давайте займемся более приятными делами — объединим эти два средства (необязательные
параметры и именованные аргументы) и посмотрим, насколько аккуратнее может стать код.
20
Dump(10, z: 3)
Возвращаясь к более раннему примеру, в листинге 13.2 было необходимо добавить метку вре-
мени в файл с применением стандартной кодировки UTF-8, однако конкретной метки времени. В
этом коде для аргумента кодировки использовалось значение null, но теперь мы можем записать
тот же код более просто, как демонстрируется в листинге 13.6.
В этой довольно простой ситуации выгода не особенно велика, но в случаях, когда нужно про-
пустить три или четыре аргумента, однако указать один последний, это действительно бесценно.
Вы видели, что необязательные параметры сокращают потребность в очень длинных списках
перегруженных версий, но есть один специфичный пример, о котором стоит упомянуть, и связан
Глава 13. Небольшие изменения, направленные на упрощение кода 416
он с неизменяемостью.
Один аспект версии C# 4 меня несколько разочаровывает — не было предпринято особо много
явных усилий, чтобы сделать неизменяемость более простой. Неизменяемые типы являются ос-
новной частью функционального программирования, и в C# постепенно появлялась все большая
и большая поддержка функционального стиля, но исключая неизменяемость.
Инициализаторы объектов и коллекций упрощают работу с изменяемыми типами, а неиз-
меняемые типы остались с носом. (Автоматически реализуемые свойства также попадают в эту
категорию.) К счастью, хотя они не проектировались специально для поддержания неизменя-
емости, именованные аргументы и необязательные параметры позволяют писать код, подобный
инициализатору объекта, который обращается к конструктору или другому фабричному методу.
Например, предположим, что вы создали класс Message, который требует указания адреса
отправителя, адреса получателя и тела сообщения, а также необязательной темы и вложения.
(Для простоты будем придерживаться варианта с единственным получателем.) Вы могли бы со-
здать изменяемый тип с подходящими записываемыми свойствами и конструировать экземпляры
приблизительно так:
Здесь присутствуют две проблемы. Во-первых, такой прием никак не принуждает предостав-
лять обязательные данные. Вы могли бы заставить указывать обязательные данные в конструкто-
ре, но тогда (до выхода C# 4) смысл аргументов не был бы очевидным:
Вполне понятно, что именованные аргументы и необязательные параметры влияют на то, как
компилятор распознает перегруженные версии — если доступно сразу несколько сигнатур метода с
одним и тем же именем, то какая из них должна быть выбрана? Необязательные параметры могут
увеличить количество подходящих методов (если некоторые методы имеют большее число пара-
метров, чем указано аргументов), а именованные аргументы — уменьшить количество подходящих
методов (за счет исключения методов, не имеющих параметров с соответствующими именами).
Большей частью изменения интуитивно понятны: чтобы проверить, является ли отдельный
метод подходящим, компилятор пытается построить список аргументов, которые могли бы пере-
даваться, используя позиционные аргументы по порядку, а затем сопоставляя именованные аргу-
менты с оставшимися параметрами. Если обязательный параметр не был указан или именованный
аргумент не соответствует ни одному из оставшихся параметров, такой метод считается неподхо-
дящим. Дополнительные сведения об этом можно найти в разделе 7.5.3 спецификации, но есть
две ситуации, которые заслуживают особого внимания.
Первая ситуация возникает, когда два метода оказываются подходящими, но в одном предостав-
лены все аргументы явным образом, тогда как в другом применяется необязательный параметр,
заполняемый стандартным значением. Преимущество получает метод, в котором не используются
какие-либо стандартные значения. Однако это не сводится просто к сравнению количества при-
Глава 13. Небольшие изменения, направленные на упрощение кода 418
Все это предполагает, что компилятор найдет множество перегруженных версий для выбора среди
них подходящей. Если некоторые методы объявлены в базовом типе, но в каком-то из производ-
ных типов имеются подходящие методы, то предпочтение будет отдано последним. Так было все-
гда, и это могло приводить к получению неожиданных результатов (дополнительные подробности
(на английском языке) и примеры ищите на веб-сайте книги: http://mng.bz/aEmE), но теперь
необязательные параметры означают возможность наличия большего числа подходящих методов,
чем можно было ожидать. Я советую избегать перегрузки метода базового класса в производном
классе, если только это не сулит крупной выгоды.
Вторая ситуация связана с тем, что именованные аргументы иногда могут быть альтернативой
приведению для оказания компилятору помощи в распознавании перегруженных версий. Време-
нами вызов может быть неоднозначным из-за того, что аргументы могут быть преобразованы в
тины параметров в двух разных методах, но во всех остальных отношениях ни один из методов
не является лучше другого. Например, рассмотрим следующие сигнатуры методов и вызов:
В прошлом имена параметров не были особо существенными, если вы имели дело исключитель-
но с языком С#. В других языках, возможно, это имело значение, но в С# параметры были важны
только во время просмотра их в IntelliSense и анализа кода интересующего метода. Теперь имена
параметров метода фактически являются частью API-интерфейса, даже если вы пользуетесь толь-
ко С#. Если когда-нибудь позже вы решите изменить их, работа кода может нарушиться — любой
вызов, в котором применялся именованный аргумент для ссылки на один из ваших параметров,
перестанет компилироваться. Это может не быть большой проблемой, если ваш код предназначен
для внутреннего потребления, но в случае написания открытого API-интерфейса имейте в виду,
что изменения имени параметра — важное событие. В действительности так было всегда, но если
весь вызывающий код написан на С#, была возможность не обращать на это внимания вплоть до
настоящего момента.
Переименование параметров — плохая затея, но перестановка имен еще хуже. Вызывающий код
может по-прежнему компилироваться, но получить другой смысл. Особенно зловредной формой
этого является переопределение метода с перестановкой мест имен параметров в переопределен-
ной версии. Компилятор всегда будет просматривать самую глубокую переопределенную версию,
о которой ему известно, на основе статического типа выражения, используемого в качестве цели
вызова метода. Вряд ли вы захотите попасть в ситуацию, когда вызов одной и той же реализации
метода с тем же самым списком аргументов ведет себя по-разному в зависимости от статического
типа переменной.
Заключение
Изменения во взаимодействии с СОМ имеют смысл не для всех компиляторов С#, и компилятор,
не реализующий эти средства, по-прежнему считается совместимым со спецификацией.
Каждый шаг в коде звучит просто: сначала создается экземпляр типа СОМ Ê и делается
видимым с помощью выражения инициализатора объекта; затем создается и заполняется новый
документ Ë.
Механизм вставки текста в документ не настолько прямолинеен, как вы могли ожидать, но
стоит вспомнить, что документ Word может иметь довольно сложную структуру; все еще не
так плохо, как могло бы быть. Пара вызовов методов принимают необязательные параметры по
ссылке; они не нужны, поэтому для них передается по ссылке локальная переменная со значением
Type.Missing. Если вам когда-либо приходилось взаимодействовать с СОМ ранее, то, скорее
всего, такой шаблон будет выглядеть хорошо знакомым.
Дальше идет по-настоящему сложный фрагмент: сохранение документа Ì. Да, метод SaveAs()
действительно имеет 16 параметров, из которых здесь задействуются только 2. И даже эти 2 пара-
метра должны передаваться по ссылке, что означает необходимость в создании для них локальных
переменных. В плане читабельности получается полный кошмар. Не переживайте — скоро мы во
всем разберемся.
Наконец, документ и приложение закрываются Í. За исключением того факта, что оба вызова
имеют по три необязательных параметра, больше нет ничего интересного.
Начнем с применения средств, которые уже были представлены в главе — даже их одних будет
достаточно, чтобы значительно сократить пример.
Это намного лучше, хотя все еще приходится создавать локальные переменные для аргументов
метода SaveAs(), которые вы указываете. Кроме того, при внимательном чтении может возник-
Глава 13. Небольшие изменения, направленные на упрощение кода 422
нуть интерес к удаленным дополнительным параметрам. Они являются параметрами ref, однако
необязательными — сочетание, которое в C# обычно не поддерживаются. Что происходит?
Итак, окончательный код стал намного яснее того, с которого все начиналось. В API-интерфейсах
вроде Word по-прежнему приходится иметь дело со сбивающим с толку набором методов, свойств
и событий в основных типах, таких как Application и Document, но, во всяком случае, ваш
код будет намного проще читать.
В терминах изменений исходного кода есть один последний аспект поддержки СОМ, который
необходимо рассмотреть, прежде чем переходить к улучшениям развертывания, доступным в C# 4.
Глава 13. Небольшие изменения, направленные на упрощение кода 423
Термин индексатор используется повсеместно в этом разделе для описания того, что в VB из-
вестно как параметризованное свойство. В спецификации CLI оно называется индексированным
свойством. Какая бы терминология не применялась, в IL это объявляется как свойство, принима-
ющее параметры. Нормальный индексатор (насколько это касается С#) определяется с помощью
стандартного члена (или стандартного свойства) для типа — например, стандартным членом
типа StringBuilder является свойство Chars (которое имеет параметр Int32). Когда здесь
речь идет об именованных индексаторах, имеются в виду те, которые не являются стандартны-
ми для типа, поэтому на них можно ссылаться по имени.
В примере снова будет использоваться Word, и на этот раз отображаются разные смысловые
трактовки для слов. В типе _Application внутри API-интерфейса Word определен индексатор
SynonymInfo с приблизительно таким объявлением:
5
Во всяком случае, непосредственно. Можно вручную применить атрибут System.Runtime.
CompilerServices.IndexerNameAttribute, но это не то, что в C# считается частью языка.
Глава 13. Небольшие изменения, направленные на упрощение кода 424
Даже без именованных индексаторов показанные ранее средства помогают облегчить пробле-
мы, присущие прежнему синтаксису C# Ê; к примеру, можно было бы вызвать app.get_Synonym
Infо("better") и получить преимущество средства необязательных параметров. Но благодаря
второму и третьему вызовам ShowInfo (Ë и Ì), легко заметить, что синтаксис индексатора вы-
глядит менее неуклюжим, чем обращение к get_. Можно было бы утверждать, что это должно
быть вызовом метода или же свойством SynonymInfo без параметров, которое возвращает кол-
лекцию с подходящим стандартным индексатором. Это один из общих аргументов, приводимых
проектировщиками C# относительно отсутствия реализации полной поддержки для именованных
индексаторов, включая их объявление в рамках С#. Но дело в том, что это уже индексатор в Word,
так что было бы неплохо им воспользоваться как таковым6 . Во втором вызове ShowInfo() Ë при-
меняется средство неявных параметров ref, описанное в разделе 13.2.3, а в третьем вызове Ì
опущен необязательный параметр и именован оставшийся аргумент (просто ради интереса).
С необязательными параметрами и индексаторами связана одна характерная особенность: если
все параметры являются необязательными, и вы не хотите указывать аргументы вообще, то долж-
ны опустить квадратные скобки. Вместо foo.Indexer[] необходимо указывать foo.Indexer.
Все это применимо как к получению значения из индексатора, так и к его установке.
Пока все идет хорошо, но написание кода — это только часть схватки. Как правило, должна
быть возможность развернуть его также на других машинах. И снова C# 4 упрощает решение
этой задачи.
Pr
ope
rti
es
Mi
cros
oft
.Of
fi
ce.
Int
erop.
Wor
dRe
fer
enc
ePr
ope
rti
es
(Name ) Micr
osof
t.
Off
ic
e.I
nte
rop.
Wor
d
Ali
a ses gl
obal
CopyL oc al Fa
lse
Culture
Desc ri
ption
Embe dInter
opT y
pes True
Fil
eT ype Assembl y
I
de ntit
y Mi cr
osoft.
Off
ic
e.I
nte
rop.Word
Path C:\Progra
mF i
les\
Micros
oftVi
sua
lSt
ud
Resolve d True
Runt i
meVe r
sion v1.1.4322
Spec i
ficVers
ion True
Str
ongNa me True
Version 12.0.0.
0
Приверженцы командной строки для связывания вместо ссылки могут указать ключ /l (или
/link) взамен /r (или /reference):
Когда сборка PIA связывается, компилятор внедряет лишь необходимые части PIA прямо в
вашу сборку. Берутся только нужные типы, и только члены внутри этих типов. Например, для
показанного ранее кода компилятор создает следующие типы:
namespace Microsoft.Office.Interop.Word
{
[ComImport, TypeIdentifier, CompilerGenerated, Guid("...")]
public interface _Application
[ComImport, TypeIdentifier, CompilerGenerated, Guid("...")]
public interface _Document
[ComImport, CompilerGenerated, TypeIdentifier, Guid("...")]
public interface Application : _Application
[ComImport, Guid("..."), TypeIdentifier, CompilerGenerated]
public interface Document : _Document
[ComImport, TypeIdentifier, CompilerGenerated, Guide("...")]
public interface Documents : IEnumerable
Глава 13. Небольшие изменения, направленные на упрощение кода 426
• Упрощается управление версиями: до тех пор, пока вы используете только члены из гой
версии библиотеки СОМ, которая действительно установлена, не имеет значения, с какой
версией сборки PIA осуществляется компиляция — с более ранней или более поздней.
• Вариантные типы трактуются как динамические типы, сокращая объем необходимых приве-
дений.
Пока не стоит переживать по поводу неясности последнего пункта — сначала нужно ознако-
миться с динамической типизацией. Все детали будут раскрыты в следующей главе.
Как видите, в Microsoft серьезно занялись взаимодействием с СОМ в версии C# 4, сделав весь
процесс разработки менее болезненным. Разумеется, уровень болезненности всегда варьировался
в зависимости от библиотеки СОМ, задействованной при разработке, поэтому от введения новых
средств одни выиграли больше, а другие — меньше.
Следующее средство совершенно не связано с СОМ, именованными аргументами и необяза-
тельными параметрами, но оно снова направлено на некоторое облегчение процесса разработки.
7
Во всяком случае, в близкий к показанному код. Инициализатор объекта делает его чуть более сложным, т.к.
компилятор применяет дополнительную временную переменную.
Глава 13. Небольшие изменения, направленные на упрощение кода 427
01101101
00110011
Этап
компиляции 00011110
11011001
App.cs PIA.dll
Ссылка Связывание
01101101 01101101
00110011 Арр.ехе 00110011 Арр.ехе
00011101 00011 01
11011011 11011 10
01101101
Время 00110011
выполнения 00011110 PIA.dll
11011001
00110101 00110101
01101000 01101000
COM.dll COM.dll
10010111 10010111
11001101 11001101
interface IStorage<T>
{
byte[] Serialize(Т value);
T Deserialize(byte[] data);
}
На этот раз экземпляр IStorage<T> для конкретного типа Т нельзя трактовать как ре-
ализацию этого интерфейса для более или менее специализированного топа. Если вы попы-
таетесь использовать его ковариантным путем (например, считать IStorage<Customer> как
IStorage<Person>), может получиться вызов метода Serialize() с объектом, который не
удастся обработать. Подобным же образом, если вы попробуете применять его контравариантным
способом, при десериализации определенных данных может быть получен непредвиденный тип.
Если это поможет, то думайте об инвариантности как о чем-то, похожем на параметры ref; для
передачи по ссылке переменная должна быть точно такого же типа, как сам параметр, поскольку
значение поступает в метод и затем благополучно получается из него обратно.
В этот раз для демонстрации идей будут использоваться знакомые интерфейсы с несколькими
простыми типами, определенными пользователем, для аргументов типов.
Листинг 13.12. Использование вариантности для построения списка общих фигур из специ-
ализированных списков
{
public int Compare(IShape x, IShape y)
{
return x.Area.CompareTo(y.Area);
}
}
...
IComparer<IShape> areaComparer = new AreaComparer();
circles.Sort(areaComparer); ❷ Сортировка с применением контравариантности
Здесь нет ничего сложного. Класс AreaComparer Ê настолько прост, насколько может быть
проста реализация IComparer<T>; к примеру, он не нуждается в поддержке состояния. Обычно в
методе Compare() присутствует некоторая обработка значений null, но для целей демонстрации
она необязательна.
Полученный экземпляр IComparer<IShape> используется для сортировки списка кружочков
Глава 13. Небольшие изменения, направленные на упрощение кода 432
Если бы кто-то предъявил вам этот код, как будто он написан на C# 3, вы могли бы посмотреть
на него и решить, что он заработает. Кажется вполне очевидным, что он должен работать, и это
распространенное впечатление; инвариантность в C# 2 и C# 3 часто оказывается неприятным сюр-
призом. Новые возможности C# 4 в этой области не вводят какие-то новые концепции, о которых
вы не знали ранее; они просто обеспечивают большую гибкость.
Это были два простых примера, в которых использовались интерфейсы с единственным ме-
тодом, но те же самые принципы применимы и к более сложным API-интерфейсам. Разумеется,
чем сложнее интерфейс, тем более вероятно, что параметр типа будет использоваться и для входа,
и для выхода, что делает его инвариантным. Позже мы рассмотрим несколько более запутанных
примеров, но сначала взглянем на делегаты.
Надеюсь, что к этому времени код требует лишь небольших пояснений. Фабрика прямоуголь-
ников всегда производит прямоугольник в той же самой позиции со сторонами длиной 10 единиц.
Ковариантность без проблем позволяет трактовать фабрику прямоугольников как фабрику общих
фигур Ê. Затем создается универсальное действие, которое выводит на консоль площадь любой
предоставленной фигуры. На этот раз используется контравариантное преобразование для трак-
товки действия как такого, которое может быть применено к любому прямоугольнику Ë. Наконец,
действию с прямоугольниками передается результат вызова фабрики прямоугольников, а действию
Глава 13. Небольшие изменения, направленные на упрощение кода 433
с формами — результат вызова фабрики форм. Как и можно было ожидать, оба действия выводят
на консоль значение 100.
Конечно, здесь использовались только делегаты с единственным параметром типа. Что про-
изойдет в случае применения делегатов или интерфейсов с несколькими параметрами типов? Что
можно сказать об аргументах типов, которые сами являются обобщенными делегатами? Понятно,
что все может стать достаточно сложным.
В листинге 13.15 показаны вариантные преобразования, доступные для делегата типа Converter
<object, string> — делегата, который принимает любой объект и производит строку. Сначала
реализуется делегат с использованием простого лямбда-выражения, в котором вызывается метод
ToString() Ê. Как только это случилось, мы больше никогда в действительности не вызываем
делегат, так что вполне можно было бы указать ссылку null. Однако я считаю, что думать о ва-
риантности проще, если привязываться к конкретному действию, которое произошло бы в случае
его вызова.
Следующие две строки кода относительно прямолинейны при условии, что вы сосредото-
чите внимание только на одном параметре типа за раз. Параметр типа TInput встречается
только во входной позиции, поэтому имеет смысл применять его контравариантно, используя
Глава 13. Небольшие изменения, направленные на упрощение кода 434
и когда это понадобится, вы должны запросить помощь у компилятора. На самом деле я просто
хотел, чтобы вы знали о таких возможностях.
С другой стороны, есть вещи, которые могут показаться доступными для выполнения, но в
действительности они не поддерживаются.
Вариантные параметры типов могут иметь только интерфейсы и делегаты. Даже при на-
личии класса, который использует параметр типа только для входа (или только для выхода),
модификаторы in или out указывать нельзя. Например, класс Comparer<T>, распространен-
ная реализация IComparer<T>, инвариантен — не существует каких-либо преобразований из
Comparer<IShape> в Comparer<Circle>.
Не принимая во внимание трудности реализации, которые могут возникнуть в связи с этим, я
бы сказал, что концептуально это имеет определенный смысл. Интерфейсы представляют способ
взгляда на объект со специфичного ракурса, тогда как классы больше направлены на действи-
тельный тип объекта. Правда, данный аргумент несколько ослабляется тем фактом, что насле-
дование позволяет трактовать объект как экземпляр любого класса в его иерархии наследования.
Так или иначе, среда CLR не разрешает это.
Вариантность нельзя применять между двумя произвольными аргументами типов лишь потому,
что между ними существует преобразование. Оно должно быть ссылочным преобразованием. По
существу это условие ограничивает до преобразований, которые оперируют на ссылочных типах
и не влияют на двоичное представление ссылки. Это сделано для того, чтобы среда CLR могла
знать, что операции будут безопасными к типам, без необходимости во внедрении повсюду кода
для действительного преобразования. Как упоминалось в разделе 13.3.2, вариантные преобразо-
вания сами по себе являются ссылочными, так что в любом случае дополнительный код будет
отсутствовать.
В частности, это ограничение запрещает любые преобразования типов значений и преобразо-
вания, определяемые пользователем. Например, все перечисленные ниже преобразования недопу-
стимы.
Преобразования, определяемые пользователем, скорее всего, нс будут проблемой, т.к. они от-
носительно редки, но ограничение, касающееся типов значений, вы можете счесть досадным.
Глава 13. Небольшие изменения, направленные на упрощение кода 436
Это стало для меня неожиданностью, хотя в ретроспективе оно имеет смысл. Рассмотрим
делегат со следующим определением:
Всякий раз, когда становятся доступными новые изменения, возникает риск нарушения рабо-
ты текущего кода. Например, если вы опираетесь на то, что результаты операций is или as не
допускают вариантность, то ваш код будет вести себя по-другому при выполнении в среде .NET
4. Более того, есть случаи, когда инструмент распознавания перегруженных версий будет выби-
Глава 13. Небольшие изменения, направленные на упрощение кода 437
рать другой метод из-за того, что теперь он стал более подходящим. Это еще одна причина для
указания вариантности явным образом: она сокращает риск нарушения работы кода.
Такие ситуации должны возникать довольно редко, и польза от вариантности значительно
превышает потенциальный ущерб, вызываемый ее недостатками. Ведь для выявления тонких из-
менений есть модульные тесты, не так ли? На самом деле команда проектировщиков языка C#
очень серьезно относится к нарушениям работы кода, но иногда введение нового средства без
такого нарушения попросту невозможно.
Этот код компилируется без проблем, поскольку существует ковариантное ссылочное преоб-
разование из выражения типа Func<string> в Func<object>. Но сам объект по-прежнему
имеет тип Func<string>, а метод Delegate.Combine(), выполняющий фактическую рабо-
ту, требует, чтобы его аргументы имели одинаковый тип — иначе он не будет знать, какой тип
делегата необходимо создать. Во время выполнения показанный выше код приведет к генерации
исключения ArgumentException.
Эта проблема была обнаружена на относительно позднем этапе цикла выпуска .NET 4, и
проектировщики в Microsoft осведомлены о ней. Есть надежда, что проблема будет устранена
для большинства случаев в будущем выпуске (в .NET 4.5 она осталась). А до тех пор придется
пользоваться обходным путем: можно создать новый объект делегата корректного типа на основе
вариантного типа делегата и объединить его с другим делегатом того же самого типа. Например,
вот как модифицировать предыдущий код, чтобы сделать его работоспособным:
Хотя но сравнению со всем остальным эта тема затрагивается исключительно ради интереса,
полезно отметить, что вариантность C# совершенно отличается от вариантности в системе Java.
Обобщенной вариантности Java удается оставаться исключительно гибкой за счет обращения к ней
с другой стороны: вместо того, чтобы вариантность объявлял сам тип, необходимую вариантность
может выражать код, использующий этот тип.
Глава 13. Небольшие изменения, направленные на упрощение кода 438
Эта книга посвящена отнюдь не обобщениям Java, но если вы заинтересовались, почитайте ста-
тью (на английском языке) Анжелики Лангер “Java Generics FAQs” (“Часто задаваемые вопросы
по обобщениям Java”), находящуюся по адресу http://mng.bz/3qgO. Будьте осторожны: это
огромная и сложная тема!
lock (listLock)
{
list.Add("item");
}
Глава 13. Небольшие изменения, направленные на упрощение кода 439
Это почти нормально —в частности, удается избежать пары проблем. Требуется гарантия того,
что освобождается тот же самый монитор, который был первоначально запрошен, поэтому ссыл-
ка для блокировки копируется во временную локальную переменную Ê. Кроме того, это также
означает, что выражение блокировки оценивается только один раз. Затем перед блоком try бло-
кировка запрашивается. Вот почему мы не пытаемся освободить блокировку в блоке finally в
случае, если поток прекращает работу без успешного ее запрашивания в первую очередь. Это при-
водит к другой проблеме: если поток прекратится после того, как блокировка запрошена, но перед
входом в блок try, то эта блокировка не будет освобождена. В результате вполне может воз-
никнуть взаимоблокировка — другой поток может бесконечно ожидать освобождения блокировки
данным потоком. Хотя на протяжении длительного времени среда CLR всячески препятствовала
этому, возможность все же исключена не полностью.
Необходим какой-то способ атомарного запроса блокировки и получения свидетельства о том,
что она была успешно запрошена. К счастью, в .NET 4 это стало возможным благодаря новой
перегруженной версии метода Monitor.Enter(), которую компилятор С# 4 применяет следую-
щим образом:
list.Add("item");
}
finally
{
if (acquired) Условное
{ ❷ освобождение
Monitor.Exit(tmp); блокировки
}
}
Теперь блокировка будет освобождаться согласованным образом тогда и только тогда, когда
она в первую очередь успешно получена.
Надо заметить, что в некоторых случаях взаимоблокировка – не самый худший результат;
Глава 13. Небольшие изменения, направленные на упрощение кода 440
иногда приложению опаснее продолжить работу, чем просто остановиться9 . Однако полагаться на
такое условие взаимоблокировки нелепо; лучше по возможности вообще избегать прекращения по-
токов. (Прекращение текущего выполняющегося потока несколько лучше, т.к. вы имеете больший
контроль — это то, что делает метод Response.Redirect() в ASP.NET, например, но я по-прежнему
рекомендую поискать более удачные формы управления потоком.)
Осталось раскрыть последнюю тему, прежде чем мы перейдем к рассмотрению действительно
крупного средства C# 4.
13.5 Резюме
Эта глава немного напоминала смесь несвязанных фрагментов, относящихся к разнообраз-
ным индивидуальным областям. С другой стороны, технология СОМ значительно выигрывает от
именованных аргументов и необязательных параметров, поэтому некоторое перекрытие между
областями все же было.
Я подозреваю, что разработчикам на C# понадобится определенное время, чтобы наловчить-
ся эффективно пользоваться новыми возможностями для параметров и аргументов. Перегруз-
ка по-прежнему обеспечивает дополнительную переносимость для языков, не поддерживающих
необязательные параметры, а именованные аргументы в некоторых ситуациях могут выглядеть
странными, пока вы не привыкнете к ним. Хотя преимущества могут быть значительными, как
9
На эту тему у Эрика Липперта есть великолепная запись в блоге, озаглавленная “Locks and exceptions do not
mix” (“Блокировки и исключения не смешиваются”): http://mng.bz/Qy7p.
Глава 13. Небольшие изменения, направленные на упрощение кода 441
В этой главе...
• Динамическое реагирование
REPL и C#
Строго говоря, среда REPL связана не только с динамическими языками. Некоторые статически
типизированные языки располагают интерпретаторами, которые компилируют на лету. Особенно
в этом плане заметен язык F#, сопровождаемый инструментом под названием F# Interactive,
который делает в точности сказанное. Однако интерпретаторы намного более распространены для
динамических языков, чем для статических.
В C# имеются похожие инструменты: средство вычисления выражений, лежащее в основе окон
Watch (Контрольное значение) и Immediate (Интерпретация) среды Visual Studio, можно считать
формой REPL, а в проекте Mono есть C# Shell (www.mono-project.com/CsharpRepl).
books.FindByAuthor("Joshua Bloch")
Однако первый фрагмент кода выглядит более уместным: вызывающему коду известна часть
Author статически, несмотря на то, что получающий код о ней не знает. В некоторых си-
туациях этот подход может использоваться для имитации предметно- ориентированных языков
(domain-specific language — DSL). Он также может применяться при создании естественного API-
интерфейса для исследования структур данных, таких как деревья XML.
Еще одна особенность программирования на динамических языках способствует экспери-
ментальному стилю программирования с использованием подходящего интерпретатора, как упо-
миналось ранее. Это не относится напрямую к C# 4, но тот факт, что C# 4 может широко
взаимодействовать с динамическими языками, функционирующими под управлением среды DLR,
означает возможность решения задачи на одном из динамических языков и потреблению резуль-
татов непосредственно в коде С#, без необходимости в последующем переносе решения в С#.
Мы рассмотрим описанные сценарии более глубоко на конкретных примерах, когда обсудим
основы динамических возможностей C# 4. Полезно кратко отметить, что если эти преимущества
к вам не применимы, то динамическая типизация, скорее всего, будет помехой, нежели помощью.
1
Статья в Википедии об утиной типизации содержит больше сведений об истории происхождения этого термина:
http://ru.wikipedia.org/wiki/Утиная типизация.
Глава 14. Динамическое связывание в статическом языке 446
• Существует неявное преобразование из любого выражения типа dynamic в почта любой тип
CLR.
Как вы увидите в разделе 14.4, подробные правила сложнее, но на данный момент давайте
придерживаться упрощенной версии.
В листинге 14.1 приведен полный пример, демонстрирующий все перечисленные выше правила.
На этот раз первым результатом является First2, который, надо надеяться, вы ожидали. Ис-
пользуя статическую типизацию, пришлось бы явно изменить объявление valueToAdd с string
на int. Тем не менее, операция сложения по-прежнему строит строку. А что если вы также
измените тип элементов на целочисленный? Давайте опробуем одну простую модификацию, пока-
занную в листинге 14.3.
Глава 14. Динамическое связывание в статическом языке 448
Листинг 14.4. Сложение целых чисел с целым числом, но без генерации исключения
Во многих примерах, показанных до сих пор, когда типы были действительно известны на этапе
компиляции, для объявления переменных можно было бы использовать var. На первый взгляд, эти
два средства выглядят очень похожими. Оба случая подобны объявлению переменной без указания
ее типа, но с помощью dynamic тип переменной явно устанавливается в динамический. Применять
var можно только в случае, если компилятор способен вывести тип переменной статически, и
система типов действительно остается полностью статической. Разумеется, если вы используете
var для переменной, которая инициализирована выражением типа dynamic, то переменная в ито-
ге также будет типизирована (статически) как dynamic. Учитывая путаницу, которую это может
вызвать, я настоятельно рекомендую не поступать так.
var арр = new Application { Visible = true }; ❶ Открыть Excel с активным рабочим листом
арр.Workbooks.Add();
Worksheet worksheet = (Worksheet) app.ActiveSheet;
Range start = (Range) worksheet.Cells[1, 1]; ❷ Определить начальную и конечную ячейки
Range end = (Range) worksheet.Cells[1, 20];
worksheet.Range[start, end].Value
= Enumerable.Range(1, 20).ToArray(); ❸ Заполнить диапазон значений [1, 20]
Этот код полагается на наличие директивы using для пространства имен Microsoft.Office
.Interop.Excel (здесь не показанной), так что на этот раз тип Application относится к
Excel, а не к Word. Кроме того, здесь применяются новые возможности C# 4, позволяющие не
указывать аргумент для необязательного параметра в вызове Workbooks.Add() при настройке
среды Ê, а также использовать именованный индексатор Ë.
Когда приложение Excel запущено, выясняются начальная и конечная ячейки всего диапазо-
на. В этом случае они находятся в одной и той же строке, но взамен можно было бы создать
прямоугольный диапазон, выбрав два противоположных угла. Создать диапазон можно было бы и
с помощью единственного вызова метода Range["А1:Т1], но лично я нахожу работу только с
числами более простой. Имена ячеек наподобие В3 хороши для людей, но труднее для применения
в программах.
Имея диапазон, можно установить все значения в нем, присваивая свойству Value массив
целых чисел Ì. Поскольку устанавливается одиночная строка, применяется одномерный массив;
для установки диапазона, охватывающего несколько строк, понадобится прямоугольный массив.
Все это работает, но в шести строках кода пришлось использовать три приведения Индексатор,
вызываемый через Cells, и свойство ActiveSheet обычно объявлены как возвращающие тип
object. (Разнообразные параметры также объявлены с типом object, но это не играет особой
Глава 14. Динамическое связывание в статическом языке 451
роли, т.к. существует неявное преобразование из любого типа, отличного от указателя, в тип
object — приведение требуется только для обратного преобразования.) Приложение Excel в
конце кода не закрывается, поэтому вы можете наблюдать на экране открытый рабочий лист.
В случае встраивания основной сборкой взаимодействия требуемых типов в ваш двоичный файл
типы во всех примерах становятся dynamic. Благодаря неявному преобразованию из dynamic в
другие типы, можно устранить все приведения, как показано в листинге 14.6.
Какой код яснее? Я сторонник старомодной статической типизации, поэтому отдаю предпочте-
ние версии из листинга 14.6. В каждой строке заявлены ожидаемые типы, поэтому если возникают
какие-то вопросы, я могу выяснить их немедленно, не дожидаясь до момента, когда буду пытаться
применить значение способом, который оно не поддерживает.
В терминах продуктивности на начальных этапах разработки оба подхода обладают как до-
стоинствами, так и недостатками. В случае использования dynamic не приходится выяснять,
какой конкретный тип ожидается; можно просто работать со значением, и до тех пор, пока все
необходимые операции поддерживаются, все в порядке.
С другой стороны, за счет применения статической типизации на каждой стадии можно про-
сматривать, что именно доступно, с помощью средства IntelliSense. Динамическая типизация по-
прежнему используется для предоставления неявного преобразования в Worksheet и Range —
только она применяется пошагово, а не одновременно. Переход от статической типизации к дина-
мической поначалу может не выглядеть чем-то особенным, т.к. этот пример относительно прост,
Глава 14. Динамическое связывание в статическом языке 452
• использование Python как механизма поддержки правил с хранением самих правил в тек-
стовом виде (даже в базе данных);
Если вы все еще не избавились от скепсиса, то примите во внимание, что внедрение сценарного
языка в широко распространенные приложения в наши дни не такая уж редкость — к примеру,
компьютерная игра Цивилизация IV Сида Мейера3 оснащена возможностью поддержки сценариев
с помощью Python. И это не просто запоздалое решение в пользу модификаций — много основных
игровых сценариев написано на Python После построения механизма разработчики обнаружили,
что он оказался более мошной средой разработки, чем предполагалось первоначально.
В этой главе я буду работать с одним примером использования Python в качестве языка кон-
фигурации. Как и пример с СОМ, он будет простым, но вполне достаточным, чтобы послужить
стартовой точкой дня дальнейшего экспериментирования, если возникнет интерес.
3
Или The Way of Life в зависимости от ваших взглядов на мир и предрасположенности к играм.
Глава 14. Динамическое связывание в статическом языке 453
Для размещения или внедрения другого языка внутрь приложения C# в зависимости от же-
лаемого уровня гибкости и контроля доступны разнообразные типы. Здесь будут применяться
только ScriptEngine и ScriptScope, поскольку требования элементарны. В данном примере
известно, что всегда будет использоваться язык Python, поэтому можно запросить у инфраструк-
туры IronPython создание экземпляра ScriptEngine напрямую; в более общих ситуациях можно
применять ScriptRuntime для динамического выбора реализации языка по имени. В более тре-
бовательных сценариях может понадобиться работать с ScriptHost и ScriptSource, а также
использовать дополнительные возможности других типов.
Не довольствуясь однократным выводом на консоль строки hello, world, этот начальный
пример сделает это дважды, один раз с применением текста, переданного механизму в виде стро-
ки, и еще раз путем загрузки файла по имени HelloWorld.py.
В листинге 14.8 представлено все необходимое.
Листинг 14.8. Вывод на консоль строки hello, world два раза с использованием Python,
внедренного в C#
Вы можете найти код в этом листинге либо довольно скучным, либо очень интересным, причем
по одной и той же причине. Он прост для понимания и требует лишь небольшого объяснения. В
терминах фактического вывода он делает немногое. . . и все же тот факт, что внедрить код Python
в С# настолько просто, является причиной для торжества. Правда, уровень взаимодействия пока
что близок к минимальному, но на самом деле вряд ли бы нашлось что-то более простое.
Файл Python содержит единственную строку: print "hello, world". Обратите внимание на
двойные кавычки в файле и сравните это с одинарными кавычками в строковом литерале, который
передается методу engine.Execute(). В обоих случаях было бы допустимым и то, и другое. В
Python поддерживаются разнообразные представления строковых литералов, включая утроенные
одинарные кавычки или утроенные двойные кавычки для многострочных литералов. Я упоминаю об
этом только потому, что удобно, когда отсутствует необходимость в обязательной отмене двойных
кавычек каждый раз, когда нужно поместить код Python в строковый литерал С#.
Оба используемых ранее метода запуска имеют перегруженные версии со вторым параметром —
областью действия. В контексте простейших терминов это может рассматриваться как словарь
имен и значений. Сценарные языки часто позволяют осуществлять присваивание переменных без
Глава 14. Динамическое связывание в статическом языке 454
явного объявления, и когда это делается на верхнем уровне программы (а не в функции или
классе), оно обычно затрагивает глобальную область действия.
Переданный в метод запуска экземпляр ScriptScope применяется в качестве глобальной
области действия для сценария, который вы предлагаете выполнить механизму. Этот сценарий
может извлекать существующие значения из области действия и создавать новые значения, как
демонстрируется в листинге 14.9.
output = input + 1
";
ScriptEngine engine = Python.CreateEngine();
ScriptScope scope = engine.CreateScope();
scope.SetVariable("input", 10); ❷ Установка переменной для использования в коде Python
engine.Execute(python, scope);
Console.WriteLine(scope.GetVariable("text")); ❸ Извлечение переменных из
области действия
Console.WriteLine(scope.GetVariable("input"));
Console.WriteLine(scope.GetVariable("output"));
В листинге 14.9 исходный код Python внедрен в код C# в виде дословного строкового литера-
ла Ê вместо помещения его в файл, что позволяет видеть весь код в одном месте. Я не рекомендую
поступать так в производственном коде, отчасти потому, что Python чувствителен к пробельным
символам — на первый взгляд безобидное переформатирование кода может привести к нарушению
его работоспособности во время выполнения.
Методы SetVariable() и GetVariable() просто помещают значения в область действия Ë
и извлекают их оттуда Ì в очевидной манере. Они объявлены с типом object, а не dynamic,
как возможно вы ожидали. Но GetVariable также позволяет указывать аргумент типа, который
действует в качестве запроса преобразования.
Это не совсем то же самое, что и приведение результата необобщенного метода, т.к. последний
просто распаковывает значение, а это означает необходимость его приведения к правильному типу.
Например, можно поместить в область действия целое число, но извлечь его как значение double:
scope.SetVariable("num", 20)
double x = scope.GetVariable<double>("num") ❶ Успешное преобразование в тип double
double у = (double) scope.GetVariable("num"); ❷ Распаковка генерирует исключение
Первый вызов проходит успешно: вы явно указываете методу GetVariable(), какой тип
нужен Ê, поэтому он автоматически преобразует значение должным образом. Второй вызов Ë
сгенерирует исключение InvalidCastException, как это было бы в любой другой ситуации с
попыткой распаковки значения с применением некорректного типа.
Область действия способна также сохранять функции, которые можно извлекать и затем вы-
зывать динамическим образом, передавая аргументы и получая возвращаемые значения. Проще
всего делать это с использованием типа dynamic (листинг 14.10).
Глава 14. Динамическое связывание в статическом языке 455
Файлам конфигурации такая возможность требуется нечасто, но она может быть удобной
в других ситуациях. Например, язык Python можно было бы легко применять для написания
сценариев в программе графического рисования, предоставляя функцию, которая должна вы-
зываться в каждой входной точке. Простой пример находится на вебсайте книги по адресу
http://mng.bz/6yGi.
Существует несколько ситуаций, когда удобно иметь средство вычисления выражений опреде-
ленного вида, которое запускает код, вводимый пользователем во время выполнения, такой как
проверка бизнес-правил для скидок, стоимости доставки и т.д. Также полезно иметь возможность
изменять эти правила в текстовой форме без необходимости в повторной компиляции или раз-
вертывании двоичных файлов. Код в листинге 14.10 довольно скучен, но еще один пример в
загружаемом исходном коде переплетает эти два языка более извилисто, демонстрируя возмож-
ность обращения в обоих направлениях: из С# в IronPython, как вы уже видели, и из IronPython
в С#.
Теперь, когда вы можете заносить значения в область действия, по существу все сделано. По-
тенциально область действия можно было бы поместить в оболочку другого объекта, обеспечивая
доступ через индексатор или даже обращаясь к значениям динамически с использованием приемов
из раздела 14.5. Код приложения может выглядеть приблизительно так:
Точная форма типа Configuration будет зависеть от вашего приложения, но вряд ли код
окажется особо захватывающим. В полном исходном коде я предоставил пример динамической
реализации, который позволяет извлекать значения как свойства и также напрямую вызывать
функции. Конечно, вы не ограничены в своей конфигурации применением элементарных типов:
код Python может быть произвольно сложным, строя коллекции, связывая компоненты и службы
и т.д. Он может играть множество ролей нормального внедрения зависимостей или контейнера
инверсии управления.
Глава 14. Динамическое связывание в статическом языке 456
Важный аспект заключается в том, что теперь вы имеете файл конфигурации, который является
активным, как противоположность традиционным пассивным файлам XML и .ini. Разумеется,
вы могли бы внедрить в пассивные файлы конфигурации собственный язык программирования, но
результат, скорее всего, оказался бы менее мощным и потребовал бы намного больших затрат на
реализацию. В качестве примера простой ситуации, где это могло бы быть удобнее, чем полной
внедрение зависимостей, можно привести необходимость конфигурирования нескольких потоков
для использования в определенном компоненте фоновой обработки внутри приложения. Обычно
можно применять столько потоков, сколько есть процессоров в системе, но иногда это количество
нужно уменьшить, чтобы способствовать более гладкому выполнению другого приложения в той
же самой системе. Файл конфигурации тогда бы изменился с такого:
agentThreads = System.Environment.ProcessorCount
agentThreadName = 'Processing agent'
на следующий:
agentThreads = 1
agentThreadName = 'Processing agent (single thread only)'
Если вы запускаете произвольный код, особенно код, вводимый внешними пользователями си-
стемы, то должны серьезно обдумать вопросы безопасности и возможно выполнять сценарий в
специальной среде песочницы. Обсуждение этой темы выходит за рамки настоящей книги, но ей
следует уделить особое внимание.
Приведенные до сих пор примеры взаимодействовали с другими системами. Тем не менее, ди-
намическая типизация может иметь смысл даже внутри полностью управляемой системы. Давайте
рассмотрим несколько примеров.
вы предлагаете инфраструктуре найти во время выполнения свойство на основе его имени. Ес-
ли вам когда-либо приходилось использовать напрямую рефлексию в собственном коде, вы снова
применяли информацию, которая доступна только во время выполнения.
По моему опыту рефлексия подвержена ошибкам, и даже если она работает, могут понадобить-
ся дополнительные усилия для ее оптимизации. В некоторых случаях динамическая типизация
может полностью заменить код, основанный на рефлексии; в зависимости от того, что конкретно
делается, динамическая типизация может также работать быстрее.
Особенно трудно использовать обобщенные типы и методы через рефлексию. Например, при
наличии объекта, который, как известно, реализует IList<T> для заданного аргумента типа Т,
может оказаться затруднительным точное выяснение того, что собой представляет Т. Если един-
ственной причиной для выяснения Т является последующий вызов другого обобщенного метода,
то вы на самом деле хотите предложить компилятору вызвать то, что он все равно вызвал бы,
будь ему известен действительный тип. Разумеется, это в точности то, что делает динамическая
типизация. Такой сценарий будет применен в качестве первого примера.
Если необходимо сделать нечто большее, чем просто вызов одиночного метода, часто лучше
помещать все дополнительные действия внутрь обобщенного метода. Затем можно вызвать этот
обобщенный метод динамически, но записать остаток кода с использованием статической типиза-
ции. Простой пример продемонстрирован в листинге 14.11.
Предположим, что имеется список некоторого типа и новый элемент из какой-то другой части
системы. Было обещано, что они совместимы, но их типы не известны статически. Это могло бы
произойти по разным причинам — например, элемент мог быть результатом десериализации. В лю-
бом случае ваш код предназначен для добавления нового элемента в конец списка, но только если
список содержит менее 10 элементов. Метод возвращает признак того, был ли добавлен элемент.
Очевидно, в реальности бизнес-логика была бы более сложной, но дело в том, что действительно
хотелось бы применять для этих операций строгие типы. В листинге 14.11 показан статически
типизированный метод и его динамический вызов.
Код преимущественно прямолинеен; он имеет почти такой же вид, как выглядели бы любые
реализации обычных перегруженных версий Sum(). Для краткости проверка source на предмет
null опущена, но остальной код достаточно прост. Есть два интересных момента, которые следует
отметить.
Во-первых, для инициализации переменной total, объявленной как dynamic, используется
операция default(Т), что обеспечивает желаемое динамическое поведение Ê. Так или иначе,
необходимо установить какое-то начальное значение; можно было бы попробовать воспользоваться
первым значением в последовательности, но тогда возникла бы проблема в случае, когда после-
довательность пуста. Для типов значений, не допускающих null, операция default(Т) почти
всегда дает подходящее значение: это естественный ноль. Для ссылочных типов первый элемент
последовательности будет сложен с null, что может как быть, так и не быть приемлемым. Для
типов значений, допускающих null, будет предприниматься попытка сложить первый элемент со
значением null для этого типа, что, безусловно, не может считаться приемлемым.
Во-вторых, результат сложения приводится обратно к типу Т, несмотря на его последующее
присваивание динамической переменной. Это может казаться странным, но вы должны вспомнить
о результате суммирования двух значений типа byte. Перед выполнением сложения компиля-
тор C# обычно поднимает тип каждого операнда до int. Без приведения переменная total в
итоге хранила бы значение int, которое затем вызвало бы исключение, когда оператор return
попытался бы преобразовать его в byte.
Оба момента порождают более глубокие вопросы, но это не является темой настоящего раздела.
Детальные исследования динамического суммирования изложены в моей статье (на английском
языке), доступной на веб-сайте книги (http://mng.bz/0N37).
Для подтверждения способности кода работать не только с обычными числами в листинге
14.13 представлен пример суммирования значений типа TimeSpan.
Утиная типизация
Иногда вы знаете, что член с определенным именем будет доступен во время выполнения, но
не можете сообщить компилятору, о каком точно члене идет речь, поскольку это будет зависеть от
типа. В некотором смысле это более общий пример только что решенной задачи за исключением
использования нормальных методов и свойств вместо операций.
Есть и отличие: обычно вы попытались бы заключить общность в интерфейс или абстрактный
базовый класс. Поступать так с операциями нельзя, но это нормальный подход для методов и
свойств. К сожалению, он работает не всегда — особенно, если задействовано множество биб-
лиотек. Наиболее согласованной в данном отношении является инфраструктура .NET Framework,
Глава 14. Динамическое связывание в статическом языке 460
но вы уже видели один пример, где это не совсем работает. В главе 12 рассматривались опти-
мизации, доступные для подсчета элементов в коллекции, и было показано, что ICollection и
ICollection<T> имеют свойство Count — однако они не располагают общим предком в виде
интерфейса с таким свойством, поэтому их приходится обрабатывать отдельно.
Утиная типизация позволяет просто обращаться к свойству Count, не выполняя проверку типа
самостоятельно (листинг 14.14).
Впервые приступив к тестированию этого кода, я использовал массив int[], который неявно
может быть преобразован в оба задействованных интерфейса. Я поначалу был удивлен, когда
метод PrintCount() отказался работать во время выполнения, и решил обдумать ситуацию более
тщательно. Связывание во время выполнения производится с применением действительного типа
объекта, которым в данном случае является int[]. Типы массивов не предоставляют открытый
доступ к свойству Count — для этого они используют явную реализацию интерфейсов. Работать
со свойством Count можно, только если рассматривать массив объекта определенным образом.
Это лишь один пример случая, когда динамическая типизация может вести себя логичным, но
неожиданным образом, если не проявить достаточную внимательность. Я непрерывно веду список
таких странностей на веб-сайте книги (http://mng.bz/5y7M); не сочтите за труд уведомить ме-
ня, если обнаружите что-нибудь новое.
Множественная диспетчеризация
Вы знаете, что во время выполнения, по меньшей мере, одна перегруженная версия метода
CountImpl() будет подходящей, т.к. параметр для метода PrintCount() имеет тип IEnumerable.
Глава 14. Динамическое связывание в статическом языке 462
С помощью динамической типизации выполняется та же самая работа, как и явные шаги “если
это ICollection<T>, то использовать одну реализацию; если это ICollection, то применять
другую реализацию”, которые использовались при выборе случайного элемента в листинге 12.17.
В качестве примера того, что это — нечто большее, чем просто применение свойства Count, когда
оно доступно, код в листинге 14.15 содержит оптимизацию для строк, позволяющую использовать
свойство Length для быстрого получения правильного результата.
Даже при условии, что здесь применяется множественная диспетчеризация, во время вы-
полнения по-прежнему могут возникать проблемы: что, если действительный тип реализует и
ICollection<string>, и ICollection<int> через явную реализацию интерфейсов? В за-
висимости от того, какая реализация Count выбрана, возможны два результата. В этом случае
связывание было бы неоднозначным, приводя к генерации исключения. К счастью, патологические
случаи подобного рода встречаются редко.
Это лишь некоторые примеры областей, где могло бы возникнуть желание использовать ди-
намическую типизацию, даже если не предпринимается никаких попыток взаимодействовать с
чем-то еще. Далее мы углубимся в детали того, как все это достигается, и завершим главу реали-
зацией собственного динамического поведения.
Должен предупредить, что рассматриваемые вопросы становятся сложнее. В действительности
все они исключительно элегантны, однако дело осложняется тем, что языки программирования
предлагают широкий набор операций, а представление всей необходимой информации об этих
операциях в виде данных и затем выполнение действий на них соответствующим образом явля-
ются сложными работами. Хорошая новость в том, что понимать это все вовсе не обязательно.
Как обычно, чем лучше вы знаете лежащие в основе механизмы, тем больше выгоды можете
получить от динамической типизации, но даже если вы будете пользоваться только приемами,
показанными до сих пор, могут возникать ситуации, в которых удастся достичь намного большей
продуктивности.
объяснял, что это такое. Это было сделано преднамеренно: я пытался четко изложить природу
динамической типизации и ее влияние на разработчиков, а не конкретные детали реализации.
Однако данная отговорка не могла продолжаться вплоть до конца главы. В чистых терминах
Dynamic Language Runtime — это библиотека, которую все динамические языки и компилятор
C# применяют для динамического выполнения кода.
Довольно-таки удивительно, но это на самом деле просто библиотека. Вопреки своему назва-
нию, она не находится на том же уровне, что и CLR (Common Language Runtime — общеязыковая
исполняющая среда) — она не имеет дела с JIT-компиляцией, маршализацией в низкоуровневом
API-интерфейсе, сборкой мусора и т.д. Однако среда DLR построена на большом объеме функци-
ональности .NET 2.0 и .NET 3.5, особенно на типах DynamicMethod и Expression. В .NET 4
также был расширен API-интерфейс деревьев выражений, чтобы позволить среде DLR представ-
лять большее число концепций. Нам рис. 14.1 показано, как все это сочетается друг с другом.
IronPython IronRuby
Связыватели
(т.e. C#, VB,
Microsoft.CSharp)
Исполняющая
среда Другие библиотеки .NET
динамического (WCF, WPF. ASP.NET и т.д.)
языка
(DLR) .NET 4.0
Рис. 14.1. Сочетание компонентов .NET 4 друг с другом, позволяющее статическим и динамическим
языкам выполняться на одной и той же платформе
В дополнение к среде DLR на рис. 14.1 присутствует еще одна библиотека, которая мо-
жет оказаться для вас новой. Одной из сборок в части связывателей на диаграмме является
Microsoft.CSharp. Она содержит несколько типов, на которые ссылается компилятор С#, ко-
гда вы используете в своем коде dynamic. Немного сбивает с толку, что она не включает суще-
ствующие классы Microsoft.CSharp.Compiler и Microsoft.CSharp.CodeDomProvider.
(Они даже не находятся в одной сборке друг с другом!) Вы увидите, для чего предназначены новые
типы, в разделе 14.4.2, где будет декомпилирован код, в котором применяется тип dynamic.
Глава 14. Динамическое связывание в статическом языке 464
Еще один важный аспект отличает среду DLR от остальной инфраструктуры .NET Framework:
она предлагается в виде исходного кода. Полный исходный код имеет форму проекта CodePiex
(http://dlr.codeplex.com), так что вы можете загрузить его и проанализировать внутрен-
нюю работу. Одно из преимуществ данного подхода заключается в том, что среду DLR не при-
шлось реализовать заново для Mono (http://mono-project.com): один и тот же код выпол-
няется под управлением как .NET, так и межплатформенной версии.
Хотя среда DLR не обрабатывает машинный код напрямую, в некотором смысле можно счи-
тать, что она выполняет работу, подобную среде CLR: точно так же, как CLR преобразует код
IL (Intermediate Language — промежуточный язык) в машинный кол, среда DLR преобразует
код, представленный с использованием связывателей, вызывающих компонентов, метаобъектов и
прочих разнообразных концепций, в деревья выражений, которые затем компилируются в код IL
и, в конечном счете — в низкоуровневый код средой CLR. На рис. 14.2 показано упрощенное
представление жизненного цикла обработки динамического выражения.
Code.cs
... ...
d.Foo(); Исходный код
...
Компилятор C#
Вызывающий IL
Связывание
компонент
Вызывающий Связыватель
компонент Объекты с
Кеширование кешированным
Кеширование (правила) поведением
(IL + правила)
Вызывающий Связыватель
Имеется машинный код,
компонент
Кеширование который можно выполнить!
(правила) (Наконец-то...)
Кеширование
(IL + машинный
код + правила)
Как видите, одним из важных аспектов среды DLR является многоуровневый кеш, Он кри-
тически важен для производительности, но чтобы понять эту и другие концепции, которые уже
упоминались, необходимо углубиться на еще один уровень.
Глава 14. Динамическое связывание в статическом языке 465
Вызывающие компоненты
Первой концепцией является вызывающий компонент. Это своего рода атом среды DLR —
мельчайшая частица кода, которая может считаться одиночной выполняемой единицей. Одно вы-
ражение может содержать множество вызывающих компонентов, но поведение выстроено есте-
ственным образом, с выполнением одного вызывающего компонента за раз.
В ходе дальнейшего обсуждения мы будем считать, что имеется только один вызывающий ком-
понент. Будет удобно ссылаться на небольшой пример вызывающего компонента, который приведен
ниже, при этом d — переменная типа dynamic:
d.Foo(10);
Вызывающий компонент представлен в коде как экземпляр типа System.Runtime.Compiler
Services.CallSite<T>. Полный пример создания и использования вызывающих компонентов
будет показан в следующем разделе, где мы взглянем на то, что компилятор C# делает на этапе
компиляции, но вот пример кода, с помощью которого может быть создан вызывающий компонент
для предыдущего фрагмента:
CallSite<Action<CallSite, object, int>>.Create(Binder.InvokeMember(
CSharpBinderFlags.ResultDiscarded, "Foo", null, typeof(Test),
new CSharpArgumentInfo[] {
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.None, null),
CSharpArgumentInfo.Create(CSharpArgumentInfoFlags.Constant |
CSharpArgumentInfoFlags.UseCompileTimeType,
null) }));
Теперь, когда есть вызывающий компонент, можно ли выполнить код? Пока еще нет.
Получатели и связыватели
прилично разбираться в DLR. Однако в умелых руках указанный интерфейс может стать мощ-
ным инструментом, предоставляющим низкоуровневое взаимодействие со средой DLR и ее кешем
выполнения. Если необходимо реализовать динамическое поведение в высокопроизводительной
манере, полезно выделить время на изучение деталей.
В инфраструктуру включены две открытых реализации интерфейса IDynamicMetaObject
Provider, которые упрощают реализацию динамического поведения в ситуациях, когда произ-
водительность не особенно критична. Мы рассмотрим все это в разделе 14.5, но пока нужно
лишь знать о самом интерфейсе и о том, что он представляет способность объекта реагировать
динамическим образом.
Если получатель не является динамическим, решение о том, как должен выполняться код,
принимает связыватель. В коде C# это предполагает применение специфичных для С# правил к
коду и выяснение, что будет делаться. Если бы вы создавали собственный динамический язык, то
могли бы реализовать специальный связыватель для принятия решения относительно его общего
поведения (когда объект не переопределяет поведение). Это выходит далеко за рамки материалов
данной книги, но само по себе является интересной темой; одной из целей DLR было как раз
упрощение реализации собственных языков.
Правила и кеши
Решение о том, как выполнять тот и иной вызов, представляется в виде правила. По суще-
ству оно состоит из двух элементов логики: обстоятельств, при которых вызывающий компонент
должен вести себя подобным образом, и самого поведения.
Первая часть в действительности предназначена для оптимизации. Предположим, что имеется
вызывающий компонент, который представляет сложение двух динамических значений, и при пер-
вой его оценке оба значения имеют тип byte. Связывателю придется предпринять немало усилий
для выяснения того, что тип обоих операндов должен быть поднят до int, а результатом должна
быть сумма этих двух целых чисел. Он может использовать данную операцию повторно каждый
раз, когда оказывается что оба операнда относятся к типу byte. Проверка набора предыдущих
результатов на предмет допустимости может сохранить массу времени. Правило, применяемое
в качестве примера (типы операндов должны точно совпадать, как было только что показано),
является распространенным, но в DLR поддерживаются также и другие правила.
Вторая часть правила — это код, предназначенный для использования, если правило удовле-
творено, и он представлен в виде дерева выражения. Код мог бы быть сохранен как скомпилиро-
ванный делегат для вызова, по представление в форме дерева выражения означает возможность
серьезной оптимизации за счет применения кеша. В среде DLR предусмотрены кеши трех уров-
ней: L0, L1 и L2. Эти кеши хранят информацию разными способами и с отличающейся областью
действия. Каждый вызывающий компонент имеет собственные кеши L0 и L1, а кеш L2 может
разделяться между множеством подобных вызывающих компонентов, как показано на рис. 14.3.
Набор вызывающих компонентов, которые разделяют кеш L2, определяется их связывателя-
ми — каждый связыватель имеет ассоциированный с ним кеш L2. Компилятор (или то, что со-
здает вызывающие компоненты) решает, сколько связывателей использовать. Компилятор может
применять связыватель для множества вызывающих компонентов, только когда они представляют
очень похожий код, где в случае одного и того же контекста во время выполнения вызывающие
компоненты должны выполняться одинаковым образом. В действительности компилятор C# не
использует эту возможность — для каждого вызывающего компонента он создает новый связыва-
тель4 , так что для разработчиков на C# особой разницы между кешами L1 и L2 не существует.
Однако по-настоящему динамические языки, такие как IronRuby и IronPython, применяют эту
возможность чаще.
4
Большая часть информации специфична для конкретного вызывающего компонента, поскольку правила связыва-
ния будут отличаться в зависимости от таких аспектов, как класс, из которого производится вызов.
Глава 14. Динамическое связывание в статическом языке 467
Сами кеши являются исполняемым кодом, что требует некоторого времени для понимания.
Компилятор C# генерирует код, чтобы просто выполнить кеш L0 вызывающего компонента (ко-
торый представляет собой делегат, доступный через свойство Target). Вот и все! Кеш L0 имеет
единственное правило, которое проверяется при его вызове. Если правило удовлетворено, выпол-
няется связанное поведение. Если же правило не удовлетворено (или это вызов в первый раз, так
что правила вообще еще нет), в действие вводится кеш L1, который, в свою очередь, вводит в
игру кеш L2. Если кеш L2 не может найти совпадающие правила, он предлагает распознать вызов
получателю или связывателю. Результаты затем помещаются в кеш для следующего раза.
В случае приведенного ранее фрагмента часть, касающаяся выполнения, будет выглядеть при-
мерно так:
callSite.Target(callSite, d, 10);
Кеши L1 и L2 просматривают свои правила довольно стандартным путем — каждый кеш име-
ет коллекцию правил, и каждое правило проверяется на предмет совпадения. Кеш L0 кое в чем
отличается. Две части его поведения (проверка правила и делегирование управления кешу L1)
скомбинированы в единственный метол, к которому затем применяется JIT-компиляция. Обновле-
ние кеша L0 предполагает повторное построение метода из нового правила.
В результате всего этого типовые вызывающие компоненты, которые часто видят похожий
контекст, являются очень быстрыми: механизм диспетчеризации настолько экономичен, что близок
к тому, что можно было бы получить при жестком кодировании проверок. Разумеется, это должно
быть соотнесено с ценой всей задействованной генерации динамического кода, но многоуровневый
кеш определенно сложен, т.к. он пытается достичь баланса между разнообразными сценариями.
Теперь вы знаете немного больше о механизме внутри DLR и в состоянии понять, что именно
компилятор делает для приведения всего в движение.
Вызывающий
Компонент
Связыватель
Кеш L0:
делегат Кеш L2:
правила
Кеш L1: (многие)
правила
(некоторые)
Вызывающий Вызывающий …
компонент компонент
Есть ситуация, которая очевидно динамическая: когда цель, на которой вызывается член, явля-
ется динамической. У компилятора нет возможности узнать, каким образом будет распознан этот
вызов. Это может быть по-настоящему динамический объект, который сам выполнит распознава-
ние, или связывателю C# возможно придется распознавать его позже с помощью рефлексии. В
любом случае шансы на статическое распознавание вызова отсутствуют.
Но когда динамическое значение используется в качестве аргумента в вызове, существуют
ситуации, в которых можно ожидать статического распознавания вызова — особенно при наличии
подходящей перегруженной версии, которая имеет тип параметра dynamic. Правило заключается
в том, что если любая часть вызова является динамической, то сам вызов становится динамиче-
ским, и перегруженная версия будет распознаваться с применением типа динамического значения
во время выполнения. В листинге 14.16 это демонстрируется на примере метода с двумя перегру-
женными версиями и его вызова несколькими отличающимися путями.
Оба вызова метода Execute() связаны динамически. Во время выполнения они распозна-
ются с использованием типов действительных значений, а именно — string и int. Параметр
типа dynamic трактуется, как если бы он был объявлен с типом object везде, кроме внут-
ренностей самого метода — взглянув на скомпилированный код, вы увидите, что он является
параметром типа object, просто с примененным дополнительным атрибутом. Это также означает
невозможность наличия двух методов, сигнатуры которых отличаются только типами параметров
dynamic/object.
Это был пример распознавания вызовов методов, но есть много других выражений, которые
предстоит рассмотреть. Поверьте, иногда ситуация не настолько проста, как приведенная выше. . .
Во время представления типа dynamic в разделе 14.2 я должен был проявлять осторожность
и не обобщать все слишком сильно, поскольку для практически каждого правила существуют
исключения. Хотя вы должны знать о них, беспокоиться об этом вовсе не нужно — вряд ли они
приведут к каким-либо проблемам.
Давайте кратко пройдемся по ним.
Глава 14. Динамическое связывание в статическом языке 469
Преобразования между типами CLR и dynamic ограничены тем же самым способом, который
не позволяет выполнять преобразование из любого типа CLR в object; исключениями являются
типы вроде указателей и System.TypedReference. С учетом того, что dynamic — это просто
object на уровне CLR, исключение указанных типов не должно вызывать удивление.
Вы могли также заметить, что речь шла о преобразовании “из выражения типа dynamic” в
тип CLR, а не о преобразовании из самого типа dynamic. Это тонкое отличие оказывает помощь
во время выведения типов и в других ситуациях, в которых необходимо учитывать неявные пре-
образования между типами; вообще говоря, когда есть два типа с неявными преобразованиями
в обоих направлениях, все становится довольно затруднительным. В основном это ограничивает
набор ситуаций, в которых учитывается преобразование.
Например, рассмотрим следующий неявно типизированный массив:
dynamic d = 0;
string х = "text";
var array = new[] { d, x };
Каким должен быть выведенный тип для array? Если бы существовало неявное преобразова-
ние из dynamic в string, то типом мог бы стать string[] или dynamic[]. поэтому возникла
бы неоднозначность и ошибка на этапе компиляции. Но поскольку преобразование предусмотрено
только из выражения типа dynamic, компилятор видит преобразование из string в dynamic,
по не наоборот, и array получает тип dynamic[]. Наверное, лучше нс беспокоиться об этой
тонкости, если только вы не пытаетесь отработать конкретный сценарий из спецификации.
В ряде случаев среда CLR вполне способна вычислить выражение с применением обычных ста-
тических путей выполнения, даже если одно из подвыражений является динамическим. Например,
взгляните на показанную ниже операцию as:
dynamic d = GetValueDynamically();
string x = d as string;
Здесь нет ничего такого, что может произойти динамически — значением d либо является
ссылка на строку, либо нет. Преобразования, определенные пользователем, не применяются в слу-
чае использования операции as, поэтому компилятор C# может выдавать точно такой же код IL,
как применяемый в ситуации, когда переменная была бы объявлена с типом object.
Для использования динамических выражений знать точные детали того, что делает с ними
компилятор, не обязательно, но ознакомление с тем, как выглядит скомпилированный код, может
быть поучительным. В частности, если по какой-либо причине вам необходимо декомпилировать
свой код, вы не должны удивляться тому, какой вил имеют динамические части. Для такой
работы лично я предпочитаю пользоваться инструментом Reflector (http://mng.bz/pMXJ), но
вы можете прибегнуть и к ildasm, если хотите читать код IL напрямую.
Мы собираемся рассмотреть только один пример — я уверен, что мог бы посвятить целую главу
описанию деталей реализации, но идея состоит в том, чтобы представить вам только сущность то-
го, что предпринимает компилятор. Если вы сочтете этот пример интересным, можете продолжить
эксперименты самостоятельно. Просто помните, что точные детали специфичны для реализации;
в будущих версиях компилятора они вполне могут измениться, продолжая обеспечивать эквива-
лентное поведение. Ниже показан пример фрагмента кода, который обычно существует в методе
Main() для Snippy:
string text = "text to cut";
dynamic startIndex = 2;
string substring = text.Substring(startIndex);
Довольно просто, не так ли? Однако фрагмент содержит две динамических операции — одну
для вызова Substring() и одну (неявную) для динамического преобразования результата этого
вызова (который как раз имеет тип dynamic на этапе компиляции) в строку. В листинге 14.17
приведен декомпилированный кол для класса Snippet5 . Для экономии пространства объявление
самого класса и неявного конструктора без параметров не показано, а код сформатирован с мень-
шим числом пробельных символов.
[CompilerGenerated]
private static class <Main>o_SiteContainer0 { ❶ Хранилище вызывающих компонентов
public static CallSite<Func<CallSite, object, string>> <>p__Site1;
public static CallSite<Func<CallSite, string, object, object>>
<>p__Site2;
}
private static void Main() {
string text = "text to cut";
5
Просто чтобы напомнить: Snippet — это класс, автоматически генерируемый инструментои Snippy.
Глава 14. Динамическое связывание в статическом языке 471
He знаю, как вы, но я рад, что мне никогда не приходится писать или даже сталкиваться с
кодом подобного рода, кроме ситуации, когда нужно исследовать происходящие действия. Тем не
менее, здесь нет ничего особо нового — код, сгенерированный для итераторных блоков, деревьев
выражений и анонимных функций, также может выглядеть устрашающим.
Все вызывающие компоненты для метода сохраняются во вложенном статическом классе Ê,
поскольку их необходимо создавать только однократно. (Если бы они создавались каждый раз,
кеш был бы бесполезен!) Вполне возможно, что вызывающие компоненты могли бы создаваться
более одного раза из-за многопоточности, но если это происходит, то эффективность снижается
лишь незначительно и ленивое создание достигается вообще без блокировки. Совершенно не име-
ет значения, если один экземпляр вызывающего компонента заменяется другим. Каждый метод,
использующий динамическое связывание, имеет отдельный контейнер компонентов; это должно
предназначаться для обобщенных методов, т.к. вызывающий компонент необходимо варьировать
на основе аргументов типов. Другая реализация компилятора могла бы применять один контей-
нер компонентов для всех необобщенных методов, еще один — для всех обобщенных методов с
одиночным параметром типа и т.д.
После того, как вызывающие компоненты созданы (Ë и Ì), они активизируются. Первым ини-
циируется вызов Substring() (читайте код из самой внутренней часта оператора в направлении
наружу) и затем на результате запускается преобразование Î. В этой точке снова появляется
статически типизированное значение, так что его можно присвоить переменной substring.
Я бы хотел подчеркнуть еще один аспект этого кода: способ, которым определенная информа-
ция о статическом типе сохраняется в вызывающем компоненте. Сама информация о типе пред-
ставлена в сигнатуре делегата, используемой для аргумента типа, который относится к вызываю-
щему компоненту (Func<CallSite, string, object, object>), а флаг в соответствующем
Глава 14. Динамическое связывание в статическом языке 472
CSharpArgumentInfo указывает на то, что эта информация о типе должна применяться в связы-
вателе Í. (Несмотря на то что это цель метода, она представлена как аргумент; методы экземпляра
трактуются как статические методы с неявным первым параметром this.) Это критически важная
часть, заставляющая связыватель вести себя так, как если бы он просто перекомпилировал ваш
код во время выполнения. Давайте посмотрим, почему она настолько важна.
Идеальная модель для выяснения, как должен вести себя связыватель, предполагает представ-
ление, что вместо наличия в исходном коде динамического значения имеется значение правильного
типа: типа, к которому относится действительное значение во время выполнения6 . Это применимо
только к динамическим значениям внутри выражения; любые типы, которые известны на этапе
компиляции, по-прежнему используются при поисках, таких как распознавание членов. Я приведу
два примера, где это имеет значение.
В листинге 14.18 показан простой перегруженный метод в одиночном типе.
Важной переменной здесь является text. Ее типом на этапе компиляции будет object, но
во время выполнения она получает значение в виде ссылки на строку. Вызов метода Execute()
оказывается динамическим, поскольку в качестве одного из его аргументов используется дина-
мическая переменная d, но при распознавании перегруженных версии применяется статический
тип text, поэтому результатом будет вывод на консоль строки "dynamic, object". Если бы
6
На самом деле все несколько сложнее — что, если действительный тип является внутренним в другой сборке?
Например, было бы нежелательно использовать этот тип в качестве аргумента типа обобщенного метода через выве-
дение типов. Связыватель поддерживает понятие “наилучшего доступного типа” на основе вызывающего контекста и
действительного типа.
Глава 14. Динамическое связывание в статическом языке 473
переменная text также была объявлена как dynamic, использовалась бы другая перегруженная
версия.
В листинге 14.19 приведен похожий код, но на этот раз имеет значение получатель вызова.
class Base
{
public void Execute(object x)
{
Console.WriteLine("object");
}
}
class Derived : Base
{
public void Execute(string x)
{
Console.WriteLine("string");
}
}
...
Base receiver = new Derived));
dynamic d = "text";
receiver.Execute(d); Выводит на консоль строку "object"
В листинге 14.19 типом переменной receiver во время выполнения является Derived, по-
этому можно было бы ожидать вызова перегруженной версии метода Execute(), определенной
в классе Derived. Но на этапе компиляции переменная receiver имеет тип Base, так что свя-
зыватель ограничивает набор рассматриваемых методов только теми, которые были бы доступны,
если бы связывание метода осуществлялось статически. Несмотря на все эти решения, которые
должны быть приняты позже, определенные проверки на этапе компиляции доступны даже для
кода, полностью связываемого во время выполнения.
Как упоминалось ближе к началу этой главы, один из недостатков динамической типизации
заключается в том, что определенные ошибки, которые обычно обнаруживались бы компилятором,
откладываются до периода выполнения и проявляются в виде исключений. Во многих ситуациях
компилятор должен просто надеяться на то, что вы знаете, что делаете, но там, где он может
помочь, он поможет.
Простейшим примером может быть случай, когда вы пытаетесь вызвать метод со статически
типизированным получателем (или статический метод) и возможно ни одна из перегруженных
версий не будет допустимой, какой бы тип не имело динамическое значение во время выполнения.
В листинге 14.20 приведены три примера недопустимых вызовов, два из которых перехватываются
компилятором.
Глава 14. Динамическое связывание в статическом языке 474
И снова первый вызов скомпилируется, но даст сбой во время выполнения. Второй вызов
не скомпилируется, потому что Т не может быть одновременно int и bool, а между ними не
существует преобразований. Третий вызов не скомпилируется, т.к. Т выводится в string, а это
нарушает ограничение о том, что он должен быть типом значения.
Компилятор консервативен: он будет сообщать об ошибке, только если точно знает что неко-
торый код не может быть выполнен успешно, и проводит только относительно простые проверки
по этому поводу. Существуют ситуации, при которых человеку может быть очевидным (и дока-
зуемым) тот факт, что код работать не будет, но компилятор все равно разрешает такой код.
Конечно, если отдельная строка кода никогда не будет работать, то одиночный модульный тест,
который ее выполняет, не пройдет, поэтому упрощенная природа проверки со стороны компилято-
ра не играет особой роли при наличии хорошего покрытия кода тестами. Думайте об этом как о
дополнительной премии в случаях, когда проблему удается обнаружить.
Глава 14. Динамическое связывание в статическом языке 475
Это раскрывает наиболее важные моменты в плане того, что компилятор может сделать для
вас. Однако нельзя применять тип dynamic абсолютно везде. Существуют ограничения, часть из
которых болезненны, но большинство из них скрыты.
Как вы уже видели, компилятор помещает некоторый контекст вызова внутрь вызывающе-
го компонента. В частности, вызывающему компоненту известны статические типы, о которых
осведомлен компилятор. Но в текущих версиях C# он не знает, какие директивы using встре-
тятся в файле исходного кода, содержащем вызов. Это означает, что компилятор не располагает
сведениями о том, какие расширяющие методы будут доступны во время выполнения.
В результате не только нельзя вызывать расширяющие методы на динамических значениях,
но динамические значения также невозможно передавать расширяющим методам в качестве аргу-
ментов. Существуют два обходных пути, которые оба любезно предлагаются компилятором. Если
вы знаете, какая перегруженная версия нужна, то можете привести динамическое значение к пра-
вильному типу внутри вызова метода. В противном случае, исходя из предположения, что вам
известен статический класс, который содержит необходимый расширяющий метод, можете вы-
звать его как нормальный статический метод. В листинге 14.22 показан пример сбойного вызова
и применение обоих обходных путей.
dynamic size = 5;
var numbers = Enumerable.Range(10, 10);
var error = numbers.Take(size); Ошибка на этапе компиляции
var workaround1 = numbers.Take((int) size);
var workaround2 = Enumerable.Take(numbers, size);
Оба подхода будут также работать в ситуации, когда расширяющий метод нужно вызвать с
динамическим значением в качестве неявного значения this, хотя в таком случае приведение
становится довольно неуклюжим.
Обратите внимание, что из-за особенностей работы распознавания перегруженных версий это
означает полную невозможность использования лямбда-выражений в динамически связываемых
вызовах без приведения — даже если единственный метод, который мог бы вызываться, имеет тип
делегата, известный на этапе компиляции. Например, следующий код не скомпилируется:
Полезно отметить, что все это не теряется при взаимодействии LINQ и dynamic. Вы може-
те иметь дело со строго типизированной коллекцией с типом элементов dynamic и по-прежнему
иметь возможность применять расширяющие методы, лямбда-выражения и даже выражения запро-
сов. Коллекция может содержать объекты разных типов, и они будут вести себя соответствующим
образом во время выполнения, как показано в листинге 14.24.
Этот код выводит на консоль 20, 2.50 и 2.5. Я умышленно делил на 20 и затем умножат на
10, чтобы продемонстрировать разницу между decimal и double: тип decimal обеспечивает
точность без нормализации, потому вместо 2.5 отображается 2.50. Первое значение является
целым числом, так что применяется целочисленное лелеете; следовательно, получается значение
20, а не 25.
Глава 14. Динамическое связывание в статическом языке 477
Не допускается объявлять, что тип имеет базовый класс dynamic. Кроме того, нельзя ис-
пользовать dynamic в ограничении параметра типа или как часть набора интерфейсов, которые
тип реализует. Однако dynamic можно применять в качестве аргумента типа для базового клас-
са или при указании интерфейса для объявления переменной. Например, перечисленные ниже
объявления недействительны:
• IEnumerable<dynamic> variable;
Большинство этих ограничений, касающихся обобщений, являются результатом того, что тип
dynamic реально не существует как тип .NET. Среде CLR о нем ничего не известно — любые
случаи использования dynamic в коде транслируются в тип object, к которому соответству-
ющим образом применен атрибут DynamicAttribute. (Для типов, подобных List<dynamic>
или Dictionary<string, dynamic>, этот атрибут четко указывает, какие части типа являют-
ся динамическими.) Атрибут DynamicAttribute применяется только тогда, когда динамическая
природа должна быть представлена в метаданных; локальным переменным этот атрибут не требу-
ется, поскольку никакие средства не нуждаются в инспектировании локальных переменных после
компиляции для выявления их динамической природы.
Все динамическое поведение достигается за счет мастерства компилятора, проявляемого при
трансляции кода, и мастерства библиотеки во время выполнения. Эквивалентность dynamic и
object наглядна во многих местах, но наиболее очевидна она, пожалуй, при просмотре результа-
тов операций typeof(dynamic) и typeof(object), которые возвращают одну и ту же ссылку.
В общем случае, если вам не удается добиться желаемого с помощью типа dynamic, вспомните,
как он выглядит для среды CLR, и подумайте, может ли это прояснить проблему. Возможно, это
и не натолкнет на решение, но, во всяком случае, вы заблаговременно получите лучшее представ-
ление о том, как все будет работать.
Вот мы и рассмотрели все детали того, как C# 4 трактует dynamic, но есть еще один аспект
динамической типизации, на который крайне важно взглянуть, чтобы получить хорошее пред-
ставление об этой теме: динамическое реагирование. Одно дело иметь возможность вызова кода
Глава 14. Динамическое связывание в статическом языке 478
Строки в качестве значений в листинге 14.25 применяются просто ради удобства — можно
использовать любой объект, как и ожидается от IDictionary<string, object>. Если для
значения указать делегат, то затем его можно вызвать, как если бы он был методом объекта
expando (листинг 14.26).
Хотя это выглядит похожим на доступ к методу, о нем можно также думать, как о доступе
к свойству, которое возвращает делегат, с последующим вызовом этого делегата. Если бы вы со-
здали статически типизированный класс со свойством AddOne типа Func<int, int>, то могли
бы использовать точно такой же синтаксис. Код С#, генерируемый для вызова AddOne, на самом
деле применяет операцию “активизация члена”, а не пытается получить доступ к AddOne как к
свойству и затем активизировать его, но типу ExpandoObject известно, что делать. При жела-
нии можно также обратиться к свойству, чтобы извлечь делегат. Давайте перейдем к чуть более
длинному примеру, хотя в нем по-прежнему не будет делаться что-то особо сложное.
<root>
<branch>
<leaf />
</branch>
</root>
Без обработки списка код в листинге 14.27 был бы еще проще. Свойства XElement и ToXml
устанавливаются динамически (Ê и Ë), но это невозможно сделать для элементов либо их спис-
ков, т.к. нужные имена на этапе компиляции не известны7 . Взамен используется словарное пред-
ставление (Í и Î), которое также позволяет легко проверять повторяющиеся элементы. Нельзя
выяснить, содержит ли объект ExpandoObject значение для отдельного ключа, просто обратив-
шись к нему как к свойству; любая попытка получения доступа к свойству, которое еще было
определено, приводит к генерации исключения.
7
В этом есть определенная ирония — имена, известные статически, могут быть установлены динамически, но для
имен, известных динамически, должна использоваться статическая типизация.
Глава 14. Динамическое связывание в статическом языке 481
book author
name="..." name="..."
author
name="..."
book
books
name="..."
author
name="..."
author
book name="..."
name="..."
В листинге 14.28 представлен краткий пример того, как код, работающий с ExpandoObject,
может применяться с этим XML-документом, включая свойства ToXml и XElement. Файл books
Глава 14. Динамическое связывание в статическом языке 482
Код в листинге 14.28 не должен вызывать удивление, разве что если вы не знакомы со свой-
ством XElement.Value, которое просто возвращает текст, находящийся внутри элемента. Вывод,
полученный в результате выполнения кода из этого листинга, выглядит вполне ожидаемо:
<author name="Philip Reeve" />
Rose was remembering the illustrations from
Morally Instructive Tales for the Nursery.
Все это хорошо, но с применяемым деревом DOM связано несколько проблем.
• Оно вообще не обрабатывает атрибуты.
• Для каждого имени элемента требуются два свойства из-за необходимости в представлении
списков.
• Было бы неплохо переопределить метод ToString(), а не добавлять дополнительное свой-
ство.
• Результат является изменяемым — ничто не препятствует последующему добавлению в коде
собственных свойств.
• Несмотря на изменяемость объекта ExpandoObject, он не будет отражать изменения ле-
жащему в основе XElement (который также изменяемый).
• Существует много возможностей для возникновения конфликтов имен, например, наличие
узла, содержащего элементы Foo и FooList либо же элементы с именами XElement или
ToXml.
• Полное дерево заполняется заранее, что влечет за собой выполнение большого объема работ,
даже если интересует всего несколько узлов.
Устранение этих проблем требует большего контроля, чем только возможность установки
свойств. Давайте рассмотрим тип DynamicObject.
Для улучшения представления XML DOM мы рассмотрим все методы кроме последнего, а
в следующем разделе, посвященном реализации интерфейса IDynamicMetaObjectProvider с
нуля, обсудим метаобъекты. Вдобавок может быть полезно создать в производном типе новые чле-
ны, даже при условии, что в вызывающем коде, скорее всего, его экземпляры будут применяться
как динамические значения. Прежде чем приступить к выполнению любого из этих действий,
необходимо создать класс для удержания всех нужных членов.
Начало работы
Класс DynamicXElement является просто оболочкой для типа XElement Ê. Он будет хранить
все необходимое состояние, представляя собой воплощение важного проектного решения. Ранее
в ExpandoObject вы рекурсивно проходили по его структуре и заполняли целое отображаемое
дерево. Это действительно было необходимо, т.к. иначе позже не удалось бы перехватить доступ
к свойствам из специального кода. Очевидно, что такой подход более дорогостоящий, чем подход
с DynamicXElement, при котором элементы дерева помещаются в оболочку, только когда они на
самом деле нужны. Кроме того, это означает, что любые изменения, внесенные в XElement после
создания объекта ExpandoObject, теряются; например, если добавить дополнительные подэле-
менты, они не будут видны как свойства, поскольку они не присутствовали при получении снимка
дерева. Облегченный подход с помещением в оболочку всегда обеспечивает “актуальность” —
любые изменения, произведенные в дереве, будут видимыми через оболочку.
Недостаток этого подхода связан с тем, что вы больше не поддерживаете ту же самую идею
идентичности, которая была ранее. В случае ExpandoObject выражение root.book.author
Глава 14. Динамическое связывание в статическом языке 484
При обсуждении типа ExpandoObject упоминались два члена, которые добавляются всегда:
метод ToXml() и свойство XElement. На этот раз не придется добавлять новый метод для
преобразования объекта в строковое представление; можно переопределить нормальный метод
ToString(). Можно также предоставить свойство XElement. как при написании любого другого
класса.
Одной из замечательных особенностей типа DynamicObject является то, что если какое-
то поведение не должно быть по-настоящему динамическим, то и не придется реализовывать
его динамическим образом. Прежде чем ассоциированный метаобъект воспользуется любым из
методов ТrуХХХ(), он проверяет, существует ли член в форме прямолинейного члена CLR. Если
это так, данный член будет вызван. Это значительно упрощает жизнь.
Также в DynamicXElement будут предусмотрены два индексатора, предназначенные для
предоставления доступа к атрибутам и замены списков элементов. В листинге 14.30 показан
новый код, добавленный в класс.
В листинге 14.30 присутствует немалый объем кода, но большая его часть проста. Метод
ToString() переопределяется Ê путем передачи вызова типу XElement, и если вы желаете
реализовать эквивалентность значений, можете предпринять похожее действие для Equals()
и GetHashCode(). Свойство, возвращающее лежащий в основе элемент Ë, и индексатор для
атрибутов Ì также просты, хотя полезно отметить, что использовать XName для параметра необ-
ходимо только в индексаторе атрибутов; если во время выполнения вы предоставите строку, тип
DynamicObject позаботится о вызове неявного преобразования в XName.
Самый сложный аспект кода — понять, для какой работы быт задуман индексатор с пара-
метром типа int Í. Вероятно, проще всего это объяснить в терминах ожидаемого применения.
Идея в том, чтобы избежать необходимости в наличии дополнительного спискового свойства, за-
ставляя элемент действовать и в качестве одиночного элемента, и в качестве списка дочерних
элементов с тем же самым именем. На рис. 14.5 представлен предыдущий пример разметки XML
с несколькими выражениями, позволяющими достигать различных узлов внутри нее.
После выяснения, для чего предназначен этот индексатор, реализация становится довольно
простой, усложняемой только за счет возможности того, что вы уже можете находиться в корне
дерева Î. В противном случае нужно всего лишь запросить у элемента все его родственные
элементы и затем выбрать тот, который интересует Ï.
До сих пор мы пока не видели ничего динамического кроме возвращаемого типа Create
Instance() — ни один из приведенных примеров не будет работать, т.е. еще не написан код для
извлечения подэлементов. Давайте исправим положение.
book author
name="..." name="..."
root.book.author["name"]
author
name="..."
books book
name="..."
author
name="..."
root.book[1].author[1]
author
book name="..."
name="..."
excerpt (Текстовый узел)
root.book[2]
root.book[2].excerpt.XElement.Value
Каждый из этих методов имеет булевский возвращаемый тип для указания, успешно ли прошло
связывание. Каждый принимает в первом параметре подходящий связыватель, и если операция
логически имеет аргументы (например, аргументы для метода или индексы для индексатора), то
они представлены как object[]. Наконец, если операция может возвращать значение (это делают
все операции кроме операций установки и удаления), для его хранения определен параметр out
типа object.
Точный тип связывателя зависит от операции; для каждой операции предусмотрен собственный
тип связывателя. Например, полная сигнатура метода TryInvokeMember() выглядит следующим
образом:
Глава 14. Динамическое связывание в статическом языке 487
Некоторые языки, подобные Python, позволяют запрашивать у объекта известные ему имена.
Например, в Python можно применять функцию dir() для вывода их списка. Эта информация
полезна в среде REPL и она также может быть удобной при отладке в IDE-среде. Среда DLR делает
эту информацию доступной через метол GetDynamicMemberNames() в типах DynamicObject
и DynamicMetaObject (последний вскоре будет рассмотрен). Все, что потребуется сделать —
это переопределить данный метод для предоставления последовательности имен динамических
членов, в результате чего свойства вашего объекта станут более обнаруживаемыми. В листинге
14.33 представлена реализация указанного метода для DynamicXElement.
Как видите, понадобился лишь простой запрос LINQ. Так случается не всегда, но я подозреваю,
что многие динамические реализации будут иметь возможность использовать LINQ подобным
образом.
Необходимо удостовериться в том, что при наличии нескольких элементов с одинаковым име-
нем одно и то же значение не возвращается более одного раза, и отсортировать результат в целях
согласованности. В отладчике Visual Studio 2010 можно развернуть узел Dynamic View (Динамиче-
ское представление) динамического объекта и просмотреть имена и значения свойств (рис. 14.6).
Глава 14. Динамическое связывание в статическом языке 489
Wat
ch1 х
Name Val
ue Type
root {
< books>< bookname ="
MortalEng
ines"><
a uthorname= "
Pdy namic{Chart
er14.Dynamic
XElement}
base {
< books>< bookname ="
MortalEng
ines"><
a uthorname= "
PS yste
m.Dy na
mic.DynamicObjec
t{Chapt
er
el
eme nt <book s
>< bookna me="Mort
alEngi
nes "
><authorna me System.
Xml .
Li
nq.XEl
e ment
XEl
eme nt <book s
>< bookna me="Mort
alEngi
nes "
><authorna me System.
Xml .
Li
nq.XEl
e ment
DynamicVi
ew E
x pandingtheDy namicVi
ewwi l
lgetthedy
na micme mbersf
book {
< bookna me ="Mort
alEngi
nes"><aut
horname =
"Phil
pReeveChapter
14.DynamicXElement
DynamicView E
x pandingtheDy namicVi
ewwi l
lgetthedy
na micme mbersf
aut
hor {
< authorna me="Phi
li
pReeve"/>} Chapter
14.DynamicXElement
Dy nami
cView E
x pandingtheDy namicVi
ewwi l
lgetthedy
na micme mbersf
E
mpt y "
Nof ur t
herinformati
ononthisobj
ectcoul
dbedi scove
red" st
ring
L
oca
l os Wa
s Aut tch1 Wa
tch2
Рис. 14.6. Окно среды Visual Studio 2010, отображающее динамические свойства объекта
DynamicXElement
Развертывая узлы Dynamic View на каждом уровне, можно углубиться в детали динамического
объекта. На рис. 14.6 были последовательно развернуты узлы документа, первой книги и ее автора.
Представление Dynamic View для автора показывает, что дальнейшей информации в иерархии
больше нет.
Итак, класс DynamicXElement завершен в той степени, в какой он отвечает требовани-
ям этой главы. Я уверен, что тип DynamicObject соблюдает баланс между управляемостью
и простотой: его довольно легко понять, и он обладает гораздо меньшими ограничениями, чем
ExpandoObject. Но если вам нужен действительно полный контроль над связыванием, то при-
дется реализовать интерфейс IDynamicMetaObjectProvider напрямую.
Листинг 14.34. Финальная цель: динамический вызов методов, пока не будет угадано пра-
вильное имя
Объект не мог получить имя Rumpelstiltskin, иначе все стало бы слишком очевидным.
Вместо этого мы будем упоминать ряд других волшебников, хотя никто из них не достиг значи-
тельных высот в алхимии. Цель первых двух обращений к методам — дать в результате отрицание,
а третьего вызова метода — признать поражение. Методы будут также возвращать булевское зна-
чение, указывающее на успешность предположения, но для краткости результаты возврата здесь
не используются.
Давайте рассмотрим сначала тип Rumpelstiltskin. Не забывайте, что это не мета-объект —
он появится позже. В листинге 14.35 приведен полный код.
Вас может интересовать, по какой причине методы объявлены как возвращающие тип object, а не
bool. В моей исходной реализации на самом деле присутствовали методы void, но, к сожалению,
вызовы динамических методов рассчитаны на возвращение чего-либо, и, согласно моему опыту,
связыватель всегда ожидает тип object. (Предусмотрено свойство ReturnType, которое можно
проверить.) Это приводит к тому, что вызов метода void сгенерирует исключение во время выпол-
нения, и то же самое справедливо в отношении метода bool; для корректного сопоставления ти-
пов понадобится самостоятельно обеспечить упаковку. Можно было бы встроить упаковку в дерево
выражения, но это намного сложнее, чем изменение возвращаемого типа метода. С тонкостями по-
добного рода придется иметь дело при реализации интерфейса IDynamicMetaObjectProvider
в реальности.
Строго говоря, два метода ответов не нужны. Когда вы строите поведение для реагирования
на входящие вызовы методов, то могли бы представить такую логику непосредственно в дереве
выражения. Но поступать так относительно сложно по сравнению с обычным возвращением дерева
выражения, которое вызывает правильный метод. Хотя если говорить более конкретно, то в данном
случае это не было бы слишком сложным; в других ситуациях все может оказаться намного
хуже. В сущности, вы создадите шлюз между статическим и динамическим мирами, реагируя на
вызовы динамических методов за счет перенаправления их статическим методам с подходящими
аргументами. Это приводит к более простому коду в метаобъекте.
С учетом сказанного, давайте, наконец, взглянем на код класса MetaRumpelstiltskin — он
показан в листинге 14.36 вместе с закрытым вложенным классом.
Печатая это, я почти представлял ваши остекленевшие глаза. Листинг 14.36 содержит сложный
для понимания код, который выглядит как приложение огромных трудозатрат для выполнения
столь простой работы. Не забывайте о том, что это вряд ли вам когда-либо понадобится, поэтому
расслабьтесь и постарайтесь уловить изюминку кода, прежде чем детали поглотят вас.
Первая половина кода по-настоящему проста. Мы сохраняем данные MethodInfo для двух
методов ответов в статических переменных Ê (они не меняются для разных экземпляров) и объяв-
ляем конструктор, который ничего не делает, но передает свои параметры конструктору базового
класса Ë. Вся реальная работа выполняется в методе BindInvokeMember() Ì, который должен
выяснить два момента — каким образом объект должен реагировать на вызов метода и обстоя-
тельства, при которых это решение является действительным.
Реагировать необходимо вызовом либо RespondToRightGuess(), либо RespondToWrong
Guess(), основываясь на том. что имя метода в вызове совпадает с именем объекта. Метаобъекту
известен реальный экземпляр, поскольку он передается конструктору. Мы обращаемся к нему
снова с использованием свойства Value и запоминаем его в переменной targetObject Í. Также
потребуется дерево выражения, которое первоначально применялось для создания метаобъекта,
чтобы можно было привязывать вызов подходящего метода целиком внутри деревьев выражений.
Метод Expression.Convert() является эквивалентом, реализованным в дереве выражения,
приведения в предыдущей строке.
Зная реальный объект, можно сравнить его имя с именем привязываемого вызова метода, кото-
Глава 14. Динамическое связывание в статическом языке 493
14.6 Резюме
Такое чувство, что мы довольно далеко ушли от преимущественно статически типизирован-
ного языка С#. Мы рассмотрели ситуации, где динамическая типизация могла быть удобной, а
также ознакомились с тем, каким образом в C# 4 она становится возможной (в терминах кода
и ее внутренней работы) и как динамически реагировать на вызовы. По ходу изложения был
продемонстрирован код для СОМ и Python, использование рефлексии и некоторые сведения о
DLR.
Данная глава не задумывалась как полное руководство по функционированию среды DLR или
даже по взаимодействию с ней языка С#. Дело в том, что это весьма глубокая тема с множеством
темных закоулков. Многие проблемы настолько малоизвестны, что вы вряд ли столкнетесь с ними,
а большинство разработчиков редко пользуются даже самыми простыми сценариями. Я уверен,
что DLR заслуживает написания отдельной книги, но надеюсь, что предоставил здесь достаточно
деталей, чтобы 99% разработчиков на C# смогли продолжать, свою работу, не нуждаясь в какой-то
дополнительной информации. Если вас интересует больше сведений, то документация на веб-сайте
DLR будет хорошей отправной точкой (http://mng.bz/0M6A).
Если вы никогда не применяете тип dynamic, можете в принципе проигнорировать динамиче-
скую типизацию. Именно так я рекомендую поступать при написании подавляющего большинства
кода — в частности, я бы не использовал ее в качестве средства, позволяющего избежать со-
здания подходящих интерфейсов, базовых классов и т.д. С другой стороны, когда динамическая
типизация действительно нужна, я применяю ее настолько умеренно, насколько это возможно.
Не занимайте позицию, которую можно сформулировать так: “поскольку я использую dynamic в
данном методе, то могу также сделать динамическим все остальное”.
Глава 14. Динамическое связывание в статическом языке 494
Я вовсе не хочу, чтобы все это звучало слишком негативно. Если вы окажетесь в ситуации,
когда динамическая типизация полезна, уверен, что вы будете признательны проектировщикам
за ее наличие в C# 4. Даже если вам она никогда не понадобится в производственном коде, я
рекомендую опробовать ее на практике чисто ради интереса — лично мене нравится копаться в
ней. Вы можете также счесть среду DLR удобной даже без применения динамической типизации;
в большинстве примеров работы с кодом Python в этой главе возможности динамической типи-
зации не использовались, но применялась среда DLR для запуска сценария Python, содержащего
конфигурационные данные.
В этой и предыдущей главах были раскрыты все новые средства C# 4. Следующая глава
посвящена версии C# 5, которая сосредоточена на единственном средстве даже уже. чем вер-
сия C# 4 была сфокусирована на динамической типизации. На самом деле все вертится вокруг
асинхронности. . .
Часть V
C# 5: упрощение асинхронности
Описать версию C# 5 довольно просто: она имеет в точности одно крупное (асинхронные
функции) и два мелких средства.
Глава 15 полностью посвящена асинхронности. Цель средства асинхронных функций (часто для
краткости называемого async/await) — сделать асинхронное программирование легким... или,
по крайней мере, более легким, чем было ранее. Оно не пытается устранить сложность, прису-
щую асинхронности; вы по-прежнему должны продумывать последствия от завершения операций в
непредвиденном порядке или щелчка пользователем на кнопке до полного завершения предшеству-
ющей операции, но исчезает много побочной сложности. Это позволяет увидеть лес за деревьями
и строить надежные, читабельные решения, имея дело только с характерной для них сложностью.
В прошлом асинхронный код часто становился запутанным подобно спагетти, с логическим
путем выполнения, который перепрыгивал с метода на метод по мере того, как один асинхронный
вызов завершался, а другой начинался. Благодаря асинхронным функциям, можно писать код,
который выглядит синхронным, используя знакомые управляющие структуры, такие как циклы
и блоки try/catch/finally, но иметь асинхронный поток выполнения, который инициируется
с помощью нового ключевого слова await. По моему опыту разница в читабельности является
просто ошеломительной. Мы будем рассматривать эту тему довольно глубоко, не только в плане
поведения языка, но также в том, как это реализовано компилятором Microsoft С#.
В главе 16 будут раскрыты два оставшихся средства: небольшое изменение раздражающего
поведения цикла foreach, о котором упоминалось в главе 5, и несколько новых атрибутов, кото-
рые работают с необязательными параметрами из C# 4, чтобы сделать возможным автоматическое
предоставление компилятором номера строки, имени члена, а также имени исходного файла для
заданной порции кода. Затем я завершу это издание книги своими привычными заключительными
размышлениями.
Может показаться, что это не выглядит чем-то серьезным, учитывая тот факт, что я умышленно
преуменьшил значимость средств, раскрываемых в главе 16. Однако заблуждаться не следует;
асинхронные функции — это действительно важное событие, особенно если вы занимаетесь на-
писанием приложений Windows Store, использующих WinRT. Предлагаемый WinRT интерфейс API
построен вокруг асинхронности, чтобы справиться с проблемой неотзывчивых пользовательских
интерфейсов. Без асинхронных функций с ним было бы довольно трудно работать. Имея в своем
распоряжении средства C# 5, по-прежнему приходится думать, но зато код может получиться на-
столько ясным, что трудно было даже вообразить подобное применительно к асинхронному коду.
Итак, вместо того, чтобы продолжать описывать, до чего же все прекрасно, давайте перейдем
непосредственно к ознакомлению со средством.
ГЛАВА 15
В этой главе...
• Асинхронность в WinRT
На протяжении многих лет асинхронность попортила немало крови разработчикам. Она бы-
ла известна как полезный способ избежать связывания потока на время ожидания завершения
произвольной задачи, но еще и как истинное наказание с учетом того, что требовалось для ее
корректной реализации.
Даже внутри платформы .NET Framework (которая по-прежнему относительно нова в рам-
ках глобальной картины) мы имеем три разных модели, с помощью которых можно попытаться
упростить ситуацию.
• Библиотека параллельных задач (Task Parallel Library — TPL), которая появилась в .NET 4
и была расширена в .NET 4.5.
Вводный список тем может навести на мысль, что материал этой главы довольно скучен. Список
точен, однако он не в состоянии передать волнение, которое меня переполняет, когда я думаю об
этом средстве. К этому времени я работал с подходом async/await около двух лет, но он все
еще превращает меня в мечтательного школьника. Я твердо уверен, что он сделает для асинхрон-
ности то, что технология LINQ сделала для обработки данных в версии C# 3, исключая лишь тот
факт, что асинхронность была намного более сложной проблемой. Чтобы достичь нужного эффек-
та, читайте эту главу с соответствующим умственным настроем. Надеюсь, что по ходу дела я смогу
заразить вас своим энтузиазмом относительно данного средства.
Главное средство C# 5 построено на основе TPL, поэтому можно писать код, выглядящий
подобно синхронному, который использует асинхронность, когда это уместно. Ушло в прошлое
переплетение обратных вызовов, подписок на события и фрагментированной обработки событий;
взамен асинхронный код ясно выражает свои намерения, причем в форме, которая основана на
структурах, уже знакомых разработчикам. Новая языковая конструкция await позволяет “дожи-
даться” асинхронной операции. Такое “ожидание” выглядит очень похожим на обычное обращение
к блокированию в том, что остальной код не будет продолжать работу до тех пор, пока операция
не завершится, но это управляется так, что текущий выполняющийся поток не блокируется. Не
переживайте, если данное утверждение звучит совершенно противоречиво — по мере чтения этой
главы все станет ясно.
В версии 4.5 платформа .NET Framework охватывает асинхронность повсеместно. Она предла-
гает асинхронные версии для огромного количества операций и следует новому документирован-
ному асинхронному шаблону, основанному на задачах, чтобы обеспечить согласованность среди
множества API-интерфейсов. Кроме того, новая платформа Windows Runtime1 , используемая для
создания приложений Windows Store в Windows 8, принудительно применяет асинхронность для
всех длительно выполняющихся (или потенциально длительно выполняющихся) операций. Сло-
вом, будущее за асинхронностью, и было бы глупо не воспользоваться преимуществами новых
средств языка при попытке справиться с дополнительной сложностью. На случай, если версия
.NET 4.5 не применяется, в Microsoft создали пакет NuGet (Microsoft.Bcl.Async), который
позволяет использовать эти новые средства для целевых платформ .NET 4, Silverlight 4, Silverlight
5, Windows Phone 7.5 или Windows Phone 8.
Просто ради ясности, язык C# не являются всезнающим, чтобы догадываться, где вы можете
пожелать выполнить операции параллельно или асинхронно. Компилятор достаточно интеллектуа-
лен, но он вовсе не пытается устранить неотъемлемую сложность асинхронного выполнения. Вы
по-прежнему должны тщательно все обдумывать, но красота C# 5 в том, что необходимости в
написании всего утомительного и запутанного стереотипного кода больше нет. Не отвлекаясь на
малозначительные детали, требуемые для превращения кода в асинхронный, все усилия можно
сосредоточить на трудных элементах.
Одно предупреждение; эта тема довольно сложна. Она обладает неудачным свойством стано-
виться чрезвычайно важной (на самом деле с годами даже начинающим разработчикам нужно
будет иметь хотя бы мимолетное представление о ней), но также достаточно запутанной, что-
бы поначалу голова пошла кругом. Как и в остальных главах книги, я не буду избегать этой
сложности — мы рассмотрим, что происходит, с достаточным числом деталей.
Вполне возможно, что я немного поморочу вам голову, но надеюсь, что со временем все станет
на свои места. Если вещи начнут казаться близкими к бреду, не переживайте — дело вовсе не
1
Она широко известна как WinRT; ее не следует путать с Windows RT, которая представляет собой версию
операционной системы Windows 8, функционирующую на процессорах ARM.
Глава 15. Асинхронность с помощью async/await 498
в вас; трудность восприятия является вполне нормальной реакцией. Хорошая новость в том, что
при использовании С# 5 смысл всего этого лежит на поверхности. Положение дел усложняет-
ся, только если пытаться думать о том, что действительно происходит “за кулисами”. Конечно,
позже именно этим мы и займемся, а также взглянем, каким образом применять данное средство
максимально эффективно.
Давайте приступим.
{
label.Text = "Fetching...";
using (HttpClient client = new HttpClient())
{
button.Click += DisplayWebSiteLength; ❷ Начало извлечения страницы
await client.GetStringAsync("http://csharpindepth.com");
label.Text = text.Length.ToString(); ❸ Обновление
пользовательского интерфейса
}
}
}
...
Application.Run(new AsyncFormf));
Освобождение задач
Эти правила проще заявить, чем следовать им. В качестве упражнения может понадобиться
опробовать несколько разных способов создания кода, подобного приведенному в листинге 15.1,
без применения новых средств С# 5. Для такого предельно простого примера не слишком плохим
решением будет использование основанного на событиях метода WebClient.DownloadString
Async(). Однако когда в игру вступает более сложное управление потоком (обработка ошибок,
ожидание завершения загрузки данных с нескольких страниц и т.д.), “унаследованный” код быстро
становится трудным в сопровождении, тогда как код C# 5 может быть модифицирован вполне
естественным образом В настоящий момент метод DisplayWebSiteLength() выглядит чем-то
магическим: вы знаете, что он делает необходимую работу, но не имеете представления, как он
это делает Давайте немного отвлечемся, отложив по-настоящему сложные детали на будущее.
Обратите внимание, что типом task является Task<string>, но типом выражения await
task по-прежнему остается просто string. В этом смысле выражение await выполняет “распа-
ковывающую” операцию — во всяком случае, когда ожидаемое значение имеет тип Task<TResult>.
(Как вы увидите, ожидать можно также и другие типы, но Task<TResult> представляет собой
хорошую отправную точку.) Данный аспект await не связан напрямую с асинхронностью, но
делает жизнь проще.
Главное назначение await — избежать блокирования, пока ожидается завершение длительно
выполняющихся операций. Вас может интересовать, как все это работает в конкретных терминах
многопоточности. Значение label.Text устанавливается и в начале, и в конце метода, поэтому
разумно предположить, что оба эти оператора выполняются в потоке пользовательского интерфей-
са, и во время ожидания загрузки вебстраницы поток пользовательского интерфейса определенно
не блокируется
Трюк заключается в том, что метод на самом деле производит возврат, как только достигнуто
выражение await. Вплоть до этой точки он выполняется синхронно в потоке пользовательского
интерфейса, как это бы делал любой другой обработчик событий. Если вы поместите точку оста-
нова в первую строку и попадете на нее в отладчике, то увидите в трассировке стека, что кнопка
занята инициированием своего события Click, включая вызов метод Button.OnClick(). По
достижении await код проверяет, доступен ли уже результат, и если нет (что случится почти
наверняка), он планирует продолжение, которое должно выполняться, когда веб-операция завер-
шится. В данном примере продолжение выполняет оставшуюся часть метода, фактически перепры-
гивая на конец выражения await и возвращаясь обратно в поток пользовательского интерфейса,
что и требуется для манипулирования пользовательским интерфейсом.
Глава 15. Асинхронность с помощью async/await 501
Продолжения
Продолжение — это, в сущности, обратный вызов, который должен быть выполнен, когда асин-
хронная операция (или на самом деле любой экземпляр Task) завершается. В асинхронном методе
продолжение поддерживает управляющее состояние метода; в точности как замыкание поддержи-
вает свою среду в терминах переменных, продолжение запоминает, где оно должно быть активи-
зировано, поэтому появляется возможность продолжить выполнение с того места, где оно было
приостановлено. В классе Task предусмотрен метод, предназначенный специально для присоеди-
нения продолжений: Task.ContinueWith().
Если вы затем поместите точку останова в код после выражения await, то увидите, что
трассировка стека больше не включает вызов метода Button.OnClick() (предполагается, что
выражение await нуждается в планировании продолжения). Этот метод давно завершил свое вы-
полнение. Стек вызовов теперь содержит обычный цикл событий Windows Forms, с несколькими
уровнями инфраструктуры асинхронности поверх него. Содержимое стека вызовов будет очень по-
хоже на ситуацию с вызовом Control.Invoke() из фонового потока с целью соответствующего
обновления пользовательского интерфейса, но все это было сделано автоматически. На первых
порах такое кардинальное изменение стека вызовов прямо на глазах может лишать присутствия
духа, но оно совершенно необходимо, чтобы сделать асинхронность эффективной.
В случае если вам интересно, все это обрабатывается за счет создания компилятором сложного
конечного автомата. Это деталь реализации, которую поучительно исследовать, чтобы получить
лучшее понимание происходящего, но сначала понадобится более конкретное описание того, чего
мы пытаемся достигнуть, и что на самом деле предписывает язык.
Console.WriteLine("First");
Console.WriteLine("Second");
Вы ожидаете, что первый вызов завершится, а затем начнется второй вызов Выполнение но
порядку перетекает с одного оператора на другой. Однако модель асинхронного выполнения не
работает подобным образом. Взамен все сводится к продолжениям. Когда вы начинаете делать
что-то, вы сообщаете операции о том, что должно произойти по ее завершению. Возможно, вы
уже слышали (или применяли) термин обратный вызов для описания той же идеи, но он имеет
более широкий смысл, нежели то, что подразумевается здесь. В контексте асинхронности я ис-
пользую этот термин для ссылки на обратные вызовы, которые сохраняют управляющее состояние
Глава 15. Асинхронность с помощью async/await 502
программы, а не на произвольные обратные вызовы, предназначенные для других целей, такие как
обработчики событий в графическом пользовательском интерфейсе.
Продолжения естественным образом представлены в .NET как делегаты, и они обычно явля-
ются действиями, которые получают результаты асинхронной операции. Именно по этой причине
для применения асинхронных методов класса WebClient в версиях, предшествующих C# 5,
необходимо был привязывать разнообразные события, чтобы указать, какой код должен выпол-
няться в случае успеха, неудачи и т.д. Беда в том, что создание всех этих делегатов для сложной
последовательности шагов, в конечном тоге, становится весьма затруднительным, даже с учетом
преимуществ, предлагаемых лямбда-выражениями. Ситуация становится даже хуже, когда пред-
принимается попытка удостовериться в корректности обработки ошибок. (В удачные дни я могу
быть достаточно уверенным в том, что пути во вручную написанном асинхронном коде, обра-
батывающие успешный случай, являются корректными. Однако обычно я менее уверен в том,
правильно ли будет реагировать код в сбойной ситуации.)
По существу все, что делает await в C# — это предлагает компилятору построить про-
должение. Однако идея, выражаемая настолько просто, оказывает значительные последствия на
читабельность и здравомыслие разработчиков.
Мое ранее описание асинхронности было идеализированным. Реальное положение дел в асин-
хронном шаблоне, основанном на задачах, несколько отличается. Вместо передачи продолжения
асинхронной операции эта асинхронная операция начинается и возвращает маркер, который можно
использовать для предоставления продолжения в более позднее время. Он представляет выпол-
няющуюся операцию, которая может быть завершена перед возвратом в вызывающий код или
по-прежнему продолжать свое выполнение. Затем данный маркер применяется везде, где нужно
выразить следующую идею: “я не могу двигаться дальше до тех пор, пока эта операция не бу-
дет завершена”. Обычно маркер имеет форму Task или Task<TResult>, но это не обязательно.
Поток выполнения внутри асинхронного метода в C# 5 обычно следует описанным ниже шагам.
1. Выполнить определенную работу.
3. Возможно, выполнить какую-то дополнительную работу. (Часто делать что-либо еще, пока
асинхронная операция не завершена, не удается, и в таком случае данный шаг будет пустым.)
6. Завершиться.
Если вас не интересует в точности, что означает шаг “ожидать”, то вы могли бы сделать все
это в версии C# 4. Если вас устраивает блокировка вплоть до завершения асинхронной операции,
то маркер обычно предоставит какой-нибудь способ сделать это. В случае Task можно просто
вызвать метод Wait(). Хотя в данный момент вы занимаете ценный ресурс (поток), не выполняя
полезной работы. Это похоже на ситуацию, когда вы заказываете по телефону доставку пиццы и
затем ждете у дверей, пока она не прибудет. На самом деле желательно было бы заняться чем-то
другим, игнорируя пиццу вплоть до факта ее доставки. Здесь на выручку приходит await.
Когда вы “ожидаете” асинхронную операцию, то в действительности утверждаете следующее:
“Пройдено столько, сколько было возможно на данный момент. Выполнение продолжится после за-
вершения операции”. Но если вы не собираетесь блокировать поток, тогда что можно предпринять?
Все очень просто: можно прямо сейчас осуществить возврат. Вы будете продолжать асинхронным
образом самого себя. И если необходимо, чтобы вызывающий код узнал, когда ваш асинхронный
метод завершился, вы передаете ему обратно маркер, который при желании может блокироваться
или (более вероятно) использоваться с другим продолжением. Зачастую у вас будет целый стек
Глава 15. Асинхронность с помощью async/await 503
асинхронных методов, вызывающих друг друга — это почти как входить в “асинхронный режим”
для какого-то раздела кода. В языке нет ничего такого, с помощью чего можно было заявить, что
все должно делаться именно таким путем, но тот факт, что код, который потребляет асинхрон-
ные операции, также ведет себя как асинхронная операция, определенно поощряет это.
Контексты синхронизации
Ранее я упоминал, что одно из золотых правил кода пользовательского интерфейса заключа-
ется в том, чтобы не допускать обновление пользовательского интерфейса из другого потока. В
примере с проверкой длины веб-страницы (листинг 15.1) необходимо было удостовериться, что
код после выражения await выполняется в потоке пользовательского интерфейса. Асинхронные
функции возвращаются в правильный поток с применением SynchronizationContext — клас-
са, который существует, начиная с версии .NET 2.0, и используется другими компонентами, такими
как BackgroundWorker. Класс SynchronizationContext обобщает идею выполнения деле-
гата “в подходящем потоке”; его методы отправки сообщений Post() (асинхронный) и Send()
(синхронный) подобны методам Control.BeginInvoke() и Control.Invoke() в Windows
Forms.
Разные среды выполнения применяют разные контексты; например, один контекст может поз-
волять любому потоку из пула потоков выполнять данное ему действие. Помимо контекста синхро-
низации существует дополнительная контекстная информация, но если вы начали интересоваться,
каким образом асинхронные методы удается выполнять именно там, где это необходимо, имейте в
виду данную врезку.
За более подробными сведениями о SynchronizationContext обратитесь к статье Стивена
Клири в журнале MSDN Magazine, посвященной этой теме (http://mng.bz/5cDw). В частности,
уделите статье особое внимание, если вы являетесь разработчиком на ASP.NET: неосмотритель-
ные разработчики могут легко попасть в ловушку, устроенную контекстом ASP.NET, и создавать
взаимоблокировки внутри кода, который выглядит вполне нормальным.
Здесь имеются три блока кода (методы) и две границы (возвращаемые типы методов). В каче-
Глава 15. Асинхронность с помощью async/await 504
Пять частей на рис. 15.1 соответствуют показанному коду, как описано ниже.
• void
• Task
• Task<TResult> (для определенного типа TResult, который сам может быть параметром
типа)
Типы Task и Task<TResult> в .NET 4 представляют операцию, которая может быть еще
не завершена; тип Task<TResult> является производным от Task. Разница между ними двумя
по существу состоит в том, что Task<TResult> представляет операцию, которая возвращает
значение типа Т, в то время как Task не нуждается в генерации какого-либо результата. Тем
4
Хорошо, отчасти. Как вы увидите далее, на самом деле есть примененный атрибут, но он не является частью
сигнатуры метода, и его вполне можно проигнорировать. В действительности он используется для того, чтобы помочь
инструментам в идентификации, где пошел “реальный” код.
Глава 15. Асинхронность с помощью async/await 506
не менее, возвращать объект Task все равно удобно, т.к. он позволяет вызывающему методу
присоединять к возвращенной задаче собственные продолжения, обнаруживать ситуации, когда
задача отказала или завершилась, и т.д. В определенном смысле о Task можно думать как о
типе, подобном Task<void>, если бы такое было допустимым.
Возможность возвращения void из асинхронного метода предназначена для достижения сов-
местимости с обработчиками событий. Например, взгляните на следующий обработчик щелчка на
кнопке пользовательского интерфейса:
private async void LoadStockPrice(object sender, EventArgs e)
{
string ticker = tickerInput.Text;
decimal price = await stockPriceService.FetchPriceAsync(ticker);
priceDisplay.Text = price.ToString("c");
}
Хотя метод асинхронный, вызывающий код (метод OnClick() кнопки или любой другой
фрагмент кода инфраструктуры, генерирующий событие щелчка) на самом деле это совершен-
но не заботит. Ему даже не нужно знать, когда обработка события действительно завершена,
т.е. когда курс акций загружен и пользовательский интерфейс обновляется. Он просто вызывает
заданный обработчик событий. Тот факт, что сгенерированный компилятором код будет созда-
вать конечный автомат, который присоединяет продолжение ко всему, возвращаемому методом
FetchPriceAsync(), в сущности, является деталью реализации. Предыдущий метод можно
подписать на событие, как если он был любым другим обработчиком событий:
loadStockPriceButton.Click += LoadStockPrice;
В конце концов (и да, я добиваюсь этого умышленно), с точки зрения вызывающего кода
это просто нормальный метод. Он имеет возвращаемый тип void и параметры типа object
и EventArgs, которые делают его подходящим в качестве действия для экземпляра делегата
EventHandler.
Подписка на событие — это практически единственный случай, когда я рекомендую возвра-
щать void из асинхронного метода. Во всех остальных ситуациях вы не обязаны возвращать
какое-то конкретное значение, но лучше объявить, что метод возвращает Task. Это позволит
вызывающему коду ожидать завершения операции, обнаруживать отказы и т.д.
К сигнатуре асинхронного метода применимо одно дополнительное ограничение: ни один из
параметров не может использовать модификаторы out или ref. Это имеет смысл, т.к. указанные
модификаторы предназначены для обмена информацией с вызывающим кодом; поскольку неко-
торые асинхронные методы могут быть еще не выполненными к моменту возврата управления
в вызывающий код, значение параметра, передаваемого по ссылке, могло оказаться неустанов-
ленным. На самом деле, ситуация могла стать даже более странной: представьте себе передачу
локальной переменной для аргумента ref — асинхронный метод мог бы пытаться установить
эту переменную после того, как вызывающий метод был уже завершен. В таких действиях мало
смысла, поэтому компилятор запрещает их.
После объявления метода можно приступать к написанию его тела и ожиданию других асин-
хронных операций.
возвращаемые выражения.
Подобно yield return, существуют ограничения относительно того, где можно использо-
вать выражения await. Их нельзя применять в блоках catch или finally, в неасинхронных
анонимных функциях5 , в теле оператора lock или в небезопасном коде.
Эти ограничения направлены на обеспечение безопасности — особенно ограничения, касающи-
еся оператора lock. Если вы когда-либо обнаружите, что стремитесь удержать блокировку на
время завершения асинхронной операции, то должны перепроектировать код. Не следует обходить
ограничение компилятора, вручную вызывая методы Monitor.TryEnter() и Monitor.Exit().
С блоком try/finally — измените код так, чтобы не нуждаться в блокировке при выполнении
операции. Если это совершенно затруднительно в вашей ситуации, подумайте об использовании
класса SemaphoreSlim с его методом WaitAsync().
Выражение await является очень простым — оно просто ожидает выполнения другого выра-
жения. Но, конечно же, имеются ограничения на то, для чего можно выполнять ожидание. Просто
в качестве напоминания: речь идет о второй границе на рис. 15.1, т.е. о том, каким образом
асинхронный метод взаимодействует с другой асинхронной операцией. Неформально применять
await можно только к тому, что описывает асинхронную операцию, другими словами, к тому,
что предоставляет следующие средства:
• получение результата, который может быть возвращаемым значением, но как минимум при-
знаком успеха или отказа.
Вы можете ожидать, что все перечисленное будет выражено через интерфейсы, но это (главным
образом) не так. Задействован только один интерфейс, который покрывает часть “присоединения
продолжения”. Но даже несмотря на такую простоту, вам почти никогда не придется иметь с
ним дело напрямую. Интерфейс находится в пространстве имен System.Runtime.Compiler
Services и выглядит следующим образом:
// Реальный интерфейс в пространстве имен System.Runtime.CompilerServices
public interface INotifyCompletion
{
void OnCompleted(Action continuation);
}
Основная масса работы выражается через шаблоны, немного напоминая цикл foreach и за-
просы LINQ. Чтобы прояснить форму шаблона, я кратко представлю его, как если бы были задей-
ствованы интерфейсы, которых на самом деле нет. Сразу после этого я раскрою действительное
положение дел. Давайте взглянем на воображаемые интерфейсы:
// Внимание: в действительности все это не существует!
// Воображаемые интерфейсы для асинхронных операций, возвращающих значения
public interface IAwaitable<T>
{
5
Это лямбда-выражения и анонимные методы, которые не объявлены с async — т.е. любое объявление анонимной
функции, которое было бы допустимым в C# 4. Асинхронные анонимные функции рассматриваются в разделе 15.4.
Глава 15. Асинхронность с помощью async/await 508
IAwaiter<T> GetAwaiter();
}
public interface IAwaiter<T> : INotifyCompletion
{
bool IsCompleted { get; }
T GetResult();
// Унаследован от INotifyCompletion
// void OnCompleted(Action continuation);
}
// Воображаемые интерфейсы для асинхронных операций void
public interface IAwaitable
{
IAwaiter GetAwaiter();
}
public interface IAwaiter : INotifyCompletion
{
bool IsCompleted { get; }
void GetResult();
// Унаследован от INotifyCompletion
// void OnCompleted(Action continuation);
}
Awaitable. В свою очередь, она имеет метод GetAwaiter(), возвращающий структуру Yield
Awaitable.YieldAwaiter, которая содержит метод GetResult(), возвращающий void. Это
означает, что допускается только такое использование:
await Task.Yield();
Либо, если вы действительно хотите разделить оператор — хотя и странно, но вполне может
быть:
Здесь выражение await не возвращает значение какого-нибудь вида, поэтому его нельзя при-
своить переменной, передать в качестве аргумент метода или сделать что-то еще, что обычно
допускается для выражений, классифицируемых как значения.
Важно отметить, что поскольку Task и Task<TResult> реализуют шаблон ожидания, один
асинхронный метод можно вызывать из другого и т.д.:
public async Task<int> FooAsync()
{
string bar = await BarAsync();
// Очевидно, что код обычно будет более сложным...
return bar.Length;
}
public async Task<string> BarAsync()
{
// Асинхронный код, который мог бы вызывать другие асинхронные методы...
}
задействованы, и как выглядит стек вызовов в любой момент времени. (Я не обещаю, что это будет
просто, однако понимание самого механизма, во всяком случае, сделает его более осуществимым.)
Код выглядит так, как если бы конструкция await могла изменить смысл целого выражения.
Правда в том, что await просто действует на одиночном значении.
Предыдущая строка кода эквивалентна следующим двум:
Аналогично, результат выражения await может применяться в качестве аргумента метода или
внутри другого выражения. Опять-таки, помогает возможность отделения части, специфичной для
await, от всего остального.
Предположим, что есть два метода, GetHourlyRateAsync() и GetHoursWorked Async(),
возвращающие Task<decimal> и Task<int>, соответственно. Может быть написан следующий
сложный оператор:
AddPayment(await employee.GetHourlyRateAsync() *
await timeSheet.GetHoursWorkedAsync(employee.Id));
Здесь применяются нормальные правила оценки выражений С#, и левый операнд операции *
должен быть полностью оценен до оценки правого операнда, поэтому предыдущий оператор может
быть развернут так, как показано ниже:
Видимое поведение
Когда выполнение достигает выражения await, существуют две возможности — либо ожида-
емая асинхронная операция уже завершилась, либо нет.
Если операция уже завершилась, поток выполнения на самом деле прост — он продолжается.
Если операция дала отказ, для представления которого предусмотрено исключение, оно и будет
6
Этот пример слегка надумай, т.к. обычно для HttpClient предусматривается оператор using, но я надеюсь,
что один раз вы простите меня за отсутствие освобождения ресурсов.
Глава 15. Асинхронность с помощью async/await 511
Оценка выражения
Операция Нет
уже
завершена?
Присоединить
продолжение
Да
Возврат
Возобновить выполнение
Извлечь через продолжение
результат
Выполнение
продолжается...
Пунктирную линию можно считать как еще одну линию, входящую в верхнюю часть блок-
схемы в качестве альтернативы. Обратите внимание, что я предполагаю наличие результата у
цели выражения await. Если вы осуществляете ожидание обычного объекта Task или чего-то
подобного, то “извлечь результат” на самом деле означает “проверить успешность завершения
операции”.
Полезно остановиться и обдумать смысл понятия “возврат” из асинхронного метода. Здесь
снова есть две возможности.
• Это первое выражение await, которое фактически необходимо ожидать, поэтому где-то в
стеке по-прежнему присутствует первоначальный вызывающий метод. (Вспомните, что до
тех пор, пока не возникнет действительная потребность в ожидании, метод выполняется
синхронно.)
• Вы уже ожидаете что-то еще, поэтому находитесь внутри продолжения, которое было вы-
звано чем-нибудь. Стек вызовов почти наверняка будет значительно изменен по сравнению с
тем, который вы видели, когда впервые зашли в метод.
В первом случае все обычно сводится к возвращению объекта Task или Task<T> вызыва-
ющему методу. Очевидно, что действительный результат метода пока не доступен — даже если
значение для возвращения отсутствует как таковое, вы не знаете, будет ли метод завершен без
исключений. По этой причине задача, которая будет возвращена, должна быть незавершенной.
В последнем случае “что-нибудь”, вызывающее асинхронный метод, зависит от контекста. На-
пример, в пользовательском интерфейсе Windows Forms, если вы запустили свой асинхронный
метод в потоке пользовательского интерфейса и не переключились преднамеренно из него, то ме-
тод целиком будет выполняться в потоке пользовательского интерфейса. В первой части метода
вы будете находиться внутри какого-то обработчика события — того, который инициировал запуск
асинхронного метода. Однако позже вызов был бы совершен практически прямо из конвейера обра-
ботки сообщении, как если бы использовался метод Control.BeginInvoke(continuation).
Здесь вызывающий код — будь он конвейером обработки сообщений Windows Forms, частью ме-
ханизма пула потоков или чем-то еще — совершенно не беспокоится о вашей задаче.
Обратите внимание, что пока не достигнуто по-настоящему асинхронное выражение await,
метод выполняется полностью синхронно. Вызов асинхронного метода не похож на инициирование
новой задачи в отдельном потоке, и на вас возлагается ответственность за написание асинхрон-
ных методов так, чтобы они обеспечивали быстрый возврат. Надо сказать, что это зависит от
контекста, в котором пишется код, но вы должны, как правило, избегать выполнения в асинхрон-
ном методе работы, требующей длительного времени. Вынесите ее в другой метод, для которого
можно создать объект Task.
Теперь, когда вы понимаете, чего должны достичь, довольно легко увидеть, каким образом
применяются члены шаблона ожидания. На рис. 15.3 на самом деле показана та же блок-схема,
что и на рис. 15.2, но дополненная обращениями к этому шаблону.
Вас может интересовать, к чему все это беспокойство — по какой причине вообще полезно
иметь языковую поддержку для таких целей? Тем не менее, присоединение продолжения слож-
нее, чем может казаться. В очень простых случаях, когда поток управления полностью линеен
(выполняет определенную работу, ожидает чего-либо, выполняет дополнительную работу, ожида-
ет чего-либо еще), довольно легко представить себе, что продолжение может выглядеть подобно
лямбда-выражению, даже если это не особенно изящно. Однако если код содержит циклы или
условия и его нужно уместить внутри одного метода, то все намного затрудняется. Именно здесь
преимущества C# 5 становятся действительно явными. Хотя можно было бы утверждать, что
Глава 15. Асинхронность с помощью async/await 513
компилятор всего лишь применяет синтаксический сахар, существует гигантская разница в чита-
бельности между созданием продолжений вручную и поручением этой работы компилятору.
В отличие от простых преобразований, таких как автоматически реализуемые свойства, генери-
руемый компилятором код довольно сильно отличается от кода, который вы, возможно, написали
бы вручную, даже когда сам асинхронный метод близок к тривиальному. Мы кратко рассмотрим
эту трансформацию далее в главе, но вы уже частично видите “человека за ширмой” — вероятно,
асинхронные методы теперь стали менее загадочными.
Оценка выражения
(поддерживающего ожидание)
Возвратить
значение свойства Нет
awaiter.IsCompleted
Запомнить объект
ожидания
(он понадобится позже)
Да
Присоединить продолжение
с помощью
awaiter.OnCompleted(...)
Возврат
Возобновить
Извлечь результат выполнение через
с помощью метода продолжение
awaiter.GetResult()
Выполнение продолжается...
Типом length является int, но возвращаемый тип метода выглядит как Task<int>. Сгенери-
рованный код сам позаботится об упаковке, поэтому вызывающий код получит объект Task<int>,
который, в конечном счете, будет содержать значение, возвращенное методом, когда он завершит-
ся. Метод, возвращающий просто Task, подобен нормальному методу void — ему вообще не
нужен оператор возврата, и любые присутствующие в нем операторы возврата должны иметь
вид return;, без попытки указать какое-то значение. В любом случае задача будет также рас-
пространять любое исключение, сгенерированное внутри асинхронного метода.
Надеюсь, что теперь вы имеете неплохое представление о том, почему необходима упаковка:
возврат в вызывающий код почти наверняка произойдет до столкновения с оператором return, и
вызывающему коду нужно каким-то образом передавать информацию. Объект Task<T> (в вычис-
лительной технике он часто называется будущим) является обещанием значения — либо исклю-
чения — в более позднее время.
Как и при нормальном потоке выполнения, если оператор return встречается внутри области
действия блока try, который имеет связанный с ним блок finally (включая ситуацию, когда
все это происходит из-за наличия оператора using), то выражение, используемое для вычисления
возвращаемого значения, оценивается немедленно, но не становится результатом задачи до тех
пор, пока не будет произведена вся необходимая очистка. Это означает, что если в блоке finally
сгенерируется исключение, вы не получите задачу, которая одновременно успешна и неудачна —
отказывает только все целиком.
Возвращаясь к моменту, упомянутому ранее, именно сочетание автоматической упаковки и
распаковки обеспечивает настолько хорошую работу средства асинхронности в целом. Можете
представлять это немного похожим на LINQ: вы пишете операции, выполняемые над каждым эле-
ментом последовательности в LINQ, а упаковка и распаковка означают возможность применения
этих операций к последовательностям и получения последовательностей обратно. В асинхрон-
ном мире редко приходится явно обрабатывать задачу — вместо этого для потребления задачи к
ней применяется await, а результирующая задача создается автоматически как часть механизма
асинхронного метода.
15.3.6 Исключения
Разумеется, работа не всегда проходит гладко, и идиоматическим способом представления отка-
зов в .NET являются исключения. Подобно возвращению значения в вызывающий код, обработка
исключений требует дополнительной поддержки со стороны языка. Когда нужно сгенерировать
исключение, первоначальный вызывающий метод или асинхронный метод может не находиться в
Глава 15. Асинхронность с помощью async/await 515
стеке; и в случае использования await для асинхронной операции, которая потерпела отказ, она
может выполняться не в том же самом потоке, поэтому необходим какой-нибудь способ марша-
лизации отказа. Если думать об отказе просто как о другой разновидности результата, то имеет
смысл, чтобы исключения и возвращаемые значения обрабатывались похожим образом.
В этом разделе мы посмотрим, как исключения проходят обе границы, показанные на рис.
15.1. Давайте начнем с границы между асинхронным методом и асинхронной операцией, которую
он ожидает.
• Свойство Status задачи получает значение Faulted, когда асинхронная операция отказала
(и свойство IsFaulted возвращает true).
• Свойство Result класса Task<T> (который также ожидает завершения) аналогичным об-
разом сгенерирует исключение AggregateException.
}
// В AggregatedExceptionAwaiter
public bool IsCompleted
{
get { return task.GetAwaiter().IsCompleted; }
}
public void OnCompleted(Action continuation) ❶ Делегаты для объекта
ожидания задачи
{
task.GetAwaiter().OnCompleted(continuation);
}
public void GetResult()
{
task.Wait(); ❷ Генерирует исключение AggregateException
}
Возможно, вам нужен похожий подход для Task<T>, с применением return task.Result;
в GetResult() вместо вызова Wait(). Важный момент связан с тем, что вы делегируете нор-
мальный объект ожидания задачи для функций, которые не хотите обрабатывать самостоятельно
Ê, но обходите обычное поведение метода GetResult(), при котором происходит распаковка
исключений. На время вызова GetResult() вам известно, что задача находится в заключи-
тельном состоянии, поэтому вызов метода Wait() Ë завершится немедленно — это не нарушает
асинхронность, которую вы пытаетесь достичь.
Для использования кода необходимо просто вызвать расширяющий метод и ожидать результа-
та, как показано в листинге 15.3.
Вы можете быть в состоянии предсказать, что здесь будет: асинхронные методы никогда не
генерируют исключения прямо при вызове. Взамен для асинхронных методов, возвращающих Task
или Task<T>, любые исключения, которые были сгенерированы внутри метода (в том числе те, что
распространились из других операций, синхронных или асинхронных), как вы уже видели, просто
перемещаются в задачу. Если вызывающий метод ожидает задачу напрямую, будет получен объект
AggregateException, содержащий исключение, но если вызывающий метод применяет вместо
этого await, то исключение будет распаковано из задачи. Асинхронные методы, возвращающие
void, будут сообщать об исключении исходному объекту SynchronizationContext, который
обработает его в зависимости от контекста7 .
Если только вы действительно не заботитесь об упаковке и распаковке для отдельного кон-
текста, то можете просто перехватывать исключение, сгенерированное вложенным асинхронным
методом. В листинге 15.4 демонстрируется, насколько знакомым это выглядит.
try
{
string text = await task; ❷ Ожидание содержимого
Console.WriteLine("File contents: {0}", text);
}
catch (IOException e) ❸ Обработка отказов
ввода-вывода
{
Console.WriteLine("Caught IOException: {0}", e.Message);
}
}
static async Task<string> ReadFileAsync(string filename)
{
7
Контексты обсуждаются более подробно в разделе 15.6.4.
Глава 15. Асинхронность с помощью async/await 519
return text.Length;
}
Код в листинге 15.5 выводит на консоль строку Fetched the task и затем отказывает. Фак-
тически исключение было сгенерировано синхронно перед выводом на консоль, т.к. до проверки
допустимости Ë никаких выражений await не встречалось, но вызывающий код не увидит это
исключение вплоть до ожидания возвращаемой задачи Ê. Как правило, для проверки допустимо-
сти аргументов, которая разумно может быть выполнена без длительных временных затрат (или
Глава 15. Асинхронность с помощью async/await 520
Обработка отмены
Библиотека параллельных задач (Task Parallel Library — TPL) ввела в .NET 4 унифицирован-
ную модель отмены, использующую два типа: CancellationTokenSource и Cancellation
Token. Идея состоит в том, что можно создать экземпляр CancellationTokenSource, а затем
запросить у него объект CancellationToken, который передается асинхронной операции. От-
мену можно выполнять только на источнике (CancellationTokenSource), но это отражается
на признаке (CancellationToken). (Это означает, что один и тот же признак можно передавать
Глава 15. Асинхронность с помощью async/await 521
нескольким операциям и не переживать, что они будут создавать помехи друг другу.) Существу-
ют разнообразные способы применения признака отмены, но наиболее идиоматический подход
предполагает вызов метода ThrowIfCancellationRequested(), который сгенерирует исклю-
чение OperationCanceledException, если сам признак был отменен, и ничего не делает в
противном случае. То же самое исключение генерируется синхронными вызовами (такими как
Task.Wait()), если они отменяются.
Взаимодействие этого с асинхронными методами описано в спецификации C# 5. Согласно
спецификации, если в теле асинхронного метода генерируется любое исключение, то задача,
возвращаемая методом, будет находиться в состоянии отказа. Точный смысл состояния “отка-
за” специфичен для реализации, но в действительности, когда асинхронный метод генериру-
ет исключение OperationCanceledException (или производного от него типа, такого как
TaskCanceledException), возвращаемая задача получает состояние Canceled. Код в листин-
ге 15.7 доказывает, что это на самом деле исключение, которое приводит к отмене задачи.
Код выводит на консоль слово Canceled, а не Faulted, как можно было предположить
на основе спецификации. Когда вызывается Wait() на задаче или запрашивается ее результат
(в случае Task<T>), исключение по-прежнему генерируется внутри AggregateException, так
что вряд ли понадобится явно выполнять проверку на предмет отмены для каждой используемой
задачи.
Отсутствие состязаний?
Вас может интересовать, присутствует ли условие состязаний в листинге 15.7. В конце концов, вы
вызываете асинхронный метод и затем ожидаете, что состояние немедленно будет фиксированным.
Если бы вы на самом деле запустили новый поток, то подобная опасность возникла бы, однако это
не так. Вспомните, что до первого выражения await асинхронный метод выполняется синхрон-
но — он по-прежнему реализует упаковку результатов и исключений, но тот факт, что это делается
в асинхронном методе, не обязательно означает участие каких-то дополнительных потоков. Метод
ThrowCancellationException() не содержит выражений await, поэтому метод целиком (все
его строки кода) выполняется синхронно; вам известно, что к моменту возврата из него результат
будет доступен. В действительности среда Visual Studio предупреждает о наличии асинхронного
метода, для которого не предусмотрено выражений await, но в данном случае именно это и тре-
буется.
Важно отметить, что если ожидается операция, которая отменена, то генерируется первона-
чальное исключение OperationCanceledException. Это означает, что если не предпринять
Глава 15. Асинхронность с помощью async/await 522
никакого прямого действия, то задача, возвращаемая из асинхронного метода, также будет отме-
нена — отмена распространяется естественным образом.
В листинге 15.8 приведен несколько более реалистичный пример отмены задачи.
Первым делом запускается асинхронная операция Ë, которая просто вызывает метод Task.
Delay() для эмуляции реальной работы Ê, но предоставляет признак отмены. На этот раз дей-
ствительно задействовано несколько потоков: достигнув выражения await, управление возвра-
щается в вызывающий метол, и в этой точке запрашивается признак отмены, которая произойдет
через 1 секунду Ì. Затем организуется ожидание (синхронным образом) завершения задачи Í,
которая должна привести к исключению. Наконец, на консоль выводится состояние задачи Î.
Вывод кода из листинга 15.8 выглядит следующим образом:
Waiting for 30 seconds...
Initial status: WaitingForActivation
Caught System.Threading.Tasks.TaskCanceledException: A task was canceled.
Final status: Canceled
Это можно представлять в терминах транзитивности отмены: если операция А ожидает опера-
цию В, а операция В отменяется, то операцию А также следует считать отмененной.
Разумеется, вы не обязаны поступать подобным образом. Можно было бы перехватить исклю-
чение OperationCanceledException в методе DelayFor30Seconds() и продолжить делать
что-то еще, немедленно выполнить возврат или даже сгенерировать другое исключение. Опять-
таки, средство асинхронности — это не устранение контроля, а просто предоставление удобного
стандартного поведения.
Глава 15. Асинхронность с помощью async/await 523
Код в листинге 15.8 работает нормально в консольном приложении либо в случае его вызова внут-
ри потока из пула, но при его выполнении в потоке пользовательского интерфейса Windows Forms
(или в любом другом однопоточном контексте синхронизации) он приведет к взаимоблокировке.
Можете ли вы понять, почему? Подумайте о том, в какой поток попытается возвратиться метод
DelayFor30Seconds(), когда отложенная задача завершится, и затем учтите, в каком потоке
выполняется вызов task.Wait(). Это относительно простой пример, но заблуждение того же
самого вида приводит к возникновению проблем у некоторых разработчиков, когда они впервые
приступают к написанию асинхронного кода. По сути, проблема кроется в применении вызова
метода Wait() или свойства Result, которые оба будут блокироваться до тех пор, пока не за-
вершится связанная с ними задача. Речь идет не о том, чтобы отказаться от их использования,
а о том, что во время их применения следует проявлять особую осторожность. Вместо этого вы
должны обычно использовать await для асинхронного ожидания результатов задач.
Создаваемый делегат должен иметь сигнатуру с возвращаемым типом void, Task или Task<T>,
точно так же. как асинхронный метод. Можно захватывать переменные, как в других анонимных
функциях, и добавлять параметры. Кроме того, асинхронная операция не начинается до тех пор,
пока делегат не будет вызван, а многочисленные обращения создают множество операций. Тем не
менее, вызов делегата на самом деле запускает операцию; как и ранее, он не ожидает начала опе-
рации, и вы совершенно не обязаны использовать await с результатом асинхронной анонимной
функции. В листинге 15.9 показан чуть более полный (хотя по-прежнему бесполезный) пример.
8
На тот случай, если вы интересуетесь, асинхронные анонимные функции нельзя использовать для создания
деревьев выражении.
Глава 15. Асинхронность с помощью async/await 524
Значения здесь были намеренно выбраны такими, чтобы вторая операция завершилась быстрее
первой. Но поскольку ожидание завершения первой операции производится до вывода на консоль
результатов (с применением свойства Result, которое блокируется до тех пор, пока задача не
будет завершена — и снова проявляйте осторожность, запуская этот код!), вывод выглядит так:
Starting... х=5
Starting... х=3
Finished... х=3
Finished... х=5
First result: 10
Second result: 6
Результаты получаются в точности теми же самыми, как если бы асинхронный код был поме-
щен в асинхронный метод.
Лично я не нахожу асинхронные анонимные функции особо привлекательными, но они зани-
мают свою нишу. Несмотря на то что они не могут включаться в выражения запросов LINQ, все
равно есть случаи, когда может понадобиться выполнить трансформации данных асинхронным
образом. Необходимо просто думать обо всем процессе немного по-другому.
Мы возвратимся к этой идее, когда будем обсуждать объединение, но прежде я хочу продемон-
стрировать одну область, где асинхронные анонимные функции действительно очень удобны. Ра-
нее я обещал, что покажу другой способ выполнения энергичной проверки аргументов на предмет
допустимости в начале асинхронного метода Вы помните, что перед передачей главной операции
значение параметра необходимо проверить на равенство null. В листинге 15.10 приведен един-
ственный метод, который достигает тех же результатов, что и раздельная реализация в листинге
15.6.
Глава 15. Асинхронность с помощью async/await 525
return text.Length;
};
return func(); ❸ Вызов асинхронной функции
}
Вы заметите, что это не асинхронный метод. Если бы он был таковым, то исключение оказалось
бы упакованным в задачу вместо того, чтобы генерироваться непосредственно. Тем не менее, по-
прежнему необходимо возвратить задачу, поэтому после проверки допустимости Ê нужная работа
просто помещается в асинхронную анонимную функцию Ë, вызывается делегат Ì и возвращается
результат.
Хотя код выглядит несколько неуклюжим, он яснее, чем версия с разделением метода на две
части. Однако при этом следует учитывать влияние на производительность: такая дополнительная
упаковка достается вовсе не бесплатно. В большинстве случаев все проходит нормально, но если
вы пишете библиотеку, которая может быть задействована в критических к производительности
работах, то перед принятием данного подхода должны оценить затраты в действительном сцена-
рии.
Превосходство VB?
В версии 11 язык Visual Basic, в конце концов, получил поддержку итераторных блоков, кото-
рая существовала в С#, начиная с версии 2. Эта задержка позволила команде проектировщиков
поразмышлять о недостатках C# — реализация Visual Basic допускает анонимные итераторные
функции, разрешая такой же вид внутриметодного разделения между энергичным и отложенным
выполнением. Аналогичная возможность в языке C# (пока еще) не появилась. . .
Как уже несколько раз упоминалось, реализация (как в настоящем приближении, так и в коде,
который генерируется реальным компилятором) по существу имеет форму конечного автомата.
Компилятор генерирует закрытую вложенную структуру для представления асинхронного метода
и должен также включить метод, имеющий ту же самую сигнатуру, как у объявленного вами. Я
называю его каркасным методом — в нем не особенно много существенного, но от него зависит
все остальное.
Каркасный метод должен создать конечный автомат, заставить его выполнить один шаг (где
шаг — это любой код, выполняемый перед первым настоящим ожиданием выражения await) и
затем возвратить задачу для представления хода работ конечного автомата. (Не забывайте, что до
тех пор, пока не достигнуто первое выражение await, которое действительно необходимо ожи-
дать, выполнение является синхронным.) На этом работа данного метода завершена — конечный
автомат затем просматривает что-то другое, а продолжения, присоединенные к другим асинхрон-
ным операциям, просто сообщают конечному автомату о том, что нужно выполнить еще один шаг.
Конечный автомат сигнализирует, когда достигает конца, предоставлением подходящего результа-
та задаче, которая была возвращена ранее. На рис. 15.4 показана блок-схема описанных действий
в лучшем виде, в каком я только смог ее представить.
Разумеется, шаг “Выполнить тело метода” начинается с начала метода только при первом вызо-
ве из каркасного метода. После этого каждый раз, когда вы попадаете в данный блок, это связано
с продолжением, когда выполнение фактически продолжается с места, где оно было оставлено.
Теперь у нас есть две вещи, которые необходимо рассмотреть: каркасный метод и конечный
автомат. На протяжении большей части этого раздела будет применяться один пример асинхрон-
ного метода, который представлен в листинге 15.11.
Выполнить тело
метода, пока не
встретится выражение
await, конец метода или
исключение
Выход
из await?
Нет Да
Установить Присоединить
результат продолжение
задачи
Возврат
(Только первый раз)
Возвратить задачу
вызывающему методу
В листинге 15.11 не делается ничего полезного, но нас интересует только поток управления.
Прежде чем начать, полезно отметить несколько моментов относительно этого метода.
• Метод имеет параметр (text).
• Он имеет два выражения await разных типов: Task.Delay() возвращает объект Task, a
Task.Yield() — объект YieldAwaitable.
Первоначальная версия этого кода имеет text в качестве параметра типа string, но ком-
пилятору C# известно о проходе по строкам эффективным способом с использованием свойства
Length и индексатора, которые делают декомпилированный код более сложным.
Я не буду представлять полный декомпилированный код, хотя он доступен в загружаемом
коде. В следующих нескольких разделах мы рассмотрим ряд наиболее важных частей в нем. Если
вы декомпилируете код самостоятельно, то не увидите в точности такой же код; я переименовал
переменные и типы, чтобы их смысл стал более отчетливым, но на самом деле код аналогичен.
Давайте начнем с простейшей части — каркасного метода.
[DebuggerStepThrough]
[AsyncStateMachine(typeof(DemoStateMachine))]
static Task<int> SumCharactersAsync(IEnumerable<char> text)
{
var machine = new DemoStateMachine();
machine.text = text;
machine.builder = AsyncTaskMethodBuilder<int>.Create();
machine.state = -1;
machine.builder.Start(ref machine);
return machine.builder.Task;
}
• Поле для параметра (text). Очевидно, что таких полей будет столько же, сколько парамет-
ров.
• Поле для state, которое хранит значения, начиная с -1. Начальным значением всегда
является -1, а позже мы посмотрим, какой смысл имеют другие возможные значения.
[CompilerGenerated]
private struct DemoStateMachine : IAsyncStateMachine
{
public IEnumerable<char> text; ❶ Поля для параметров
public IEnumerator<char> iterator;
public char ch; Поля для локальных
❷
переменных
public int total;
public int unicode;
private TaskAwaiter taskAwaiter; Поля для объектов
❸
private YieldAwaitable.YieldAwaiter yieldAwaiter; ожидания
В этом примере я разделил поля на различные разделы. Вы уже видели, что поле text Ê,
представляющее первоначальный параметр, устанавливается каркасным методом, а также знако-
мы с полями builder и state, которые являются общей инфраструктурой, разделяемой всеми
конечными автоматами.
Глава 15. Асинхронность с помощью async/await 531
Каждая локальная переменная также имеет собственное поле Ë, поскольку необходимо сохра-
нять значения между вызовами метода MoveNext(). Иногда встречаются локальные переменные,
которые применяются только между двумя отдельными выражениями await и не нуждаются в
сохранении внутри полей, но согласно моему опыту текущая реализация в любом случае преду-
сматривает для них поля. Помимо всего прочего, это улучшает процесс отладки, т.к. обычно
ожидается, что локальные переменные не должны терять свои значения, даже если в дальнейшем
коде они больше не используются.
Для каждого типа объекта ожидания, применяемого в асинхронном методе, предусмотрено
одно поле, если это тип значения, и еще одно поле для всех объектов ожидания, которые являются
ссылочными типами (в терминах их типов на этапе компиляции). В данном случае имеются два
выражения await, которые используют два разных типа структур ожидания, поэтому получаются
два поля Ì. Если бы второе выражение await также работало с TaskAwaiter, или если бы
TaskAwaiter и YieldAwaiter оба были классами, тогда было бы создано единственное поле. В
каждый момент времени активным может быть только один объект ожидания, поэтому совершенно
не важно, что сохранять можно лишь одно значение за раз. Вы должны передавать объекты
ожидания между выражениями await, чтобы после завершения операции можно было получить
результат.
Из числа полей общей инфраструктуры Í вы уже видели state и builder. В качестве
напоминания, state применяется для отслеживания состояния, поэтому продолжение может по-
лучить правильную точку внутри кода. Поле builder используется для разнообразных действий,
включая создание экземпляра Task или Task<T> для возвращения каркасным методом — за-
дачи, которая впоследствии будет заполнена корректным результатом, когда асинхронный метод
завершится. Поле stack более загадочно — оно применяется в случае, если выражение await
встречается как часть оператора, которому необходимо отслеживать дополнительное состояние, не
представленное нормальными локальными переменными. Пример этого будет приведен в разделе
15.5.6 — в конечном автомате, сгенерированном для листинга 15.11, данное поле не используется.
Метод MoveNext() — это то место, где в игру вступают все интеллектуальные возможно-
сти компилятора, но прежде чем его рассматривать, нужно очень кратко взглянуть на метод
SetStateMachine(). В каждом конечном автомате он имеет одну и ту же реализацию, которая
выглядит следующим образом:
В двух словах, этот метод применяется для того, чтобы позволить упакованной копии конеч-
ного автомата иметь ссылку на саму себя внутри построителя. Я не буду вдаваться в детали
управления всей упаковкой — достаточно лишь понимать, что конечный автомат упаковывается
там, где это необходимо, а разнообразные аспекты механизма асинхронности гарантируют, что
впоследствии одиночная упакованная копия будет использоваться согласованным образом. Это
действительно важно, т.к. мы говорим об изменяемом типе значения (трепещите!).
Если бы к одной копии конечного автомата применялись одни изменения, а к другой копии —
другие, то все очень скоро бы развалилось.
При желании можно представить все по-другому, и это будет важно, если вы действительно
начнете думать о том, каким образом передаются переменные экземпляра конечного автомата. Во
избежание излишних выделений памяти в куче конечный автомат реализован в виде структуры,
однако большая часть кода пытается действовать так, как будто бы он на самом деле является
классом. Работоспособность всего этого обеспечивается ловкими фокусами со ссылками внутри
метода SetStateMachine().
Глава 15. Асинхронность с помощью async/await 532
Итак, теперь на месте все кроме действительного кода, который был в асинхронном методе.
Давайте займемся методом MoveNext().
состояния указывают цель продолжения. Конечный автомат попадает в состояние -2, когда он за-
вершен. В конечных автоматах, созданных в отладочных конфигурациях, вы увидите ссылку на
состояние -3 — но никогда не следует ожидать действительного попадания в это состояние. Оно
предназначено для того, чтобы избежать получения вырожденного оператора switch, который
приводил бы к снижению удобства отладки.
Переменная result устанавливается в ходе метода, в точке, где исходный асинхронный ме-
тод имеет оператор return. Затем она применяется в вызове builder.SetResult(), когда
достигается логический конец метода. Даже необобщенные типы AsyncTaskMethodBuilder и
AsyncVoidMethodBuilder содержат методы SetResult(); первый из них сообщает о факте
завершения метода задаче, которая была возвращена из каркасного метода, а второй сигнализирует
о завершении исходному объекту SynchronizationContext. (Исключения передаются исход-
ному объекту SynchronizationContext тем же самым способом. Это довольно грубый подход
к отслеживанию происходящего, однако он предлагает решение для ситуаций, где действительно
должны быть методы void.)
Переменная doFinallyBodies используется для выяснения, должны ли любые блоки finally
в первоначальном коде (в том числе неявные из операторов using или foreach) быть выполне-
ны, когда поток выполнения покидает область действия блока try. Концептуально блок finally
желательно выполнять только в случае покидания блока try нормальным путем. Если же произо-
шел возврат из метода, который ранее имел продолжение, присоединенное к объекту ожидания,
то метод логически “приостанавливается”, поэтому выполнять блок finally не нужно. Любые
блоки finally наряду со связанным с ними блоком try находились бы внутри раздела кода,
помеченного как “Тело метода”.
Большая часть тела метода узнаваема в понятиях первоначального асинхронного метода. Прав-
да, придется привыкнуть к тому, что все локальные переменные теперь выглядят как переменные
экземпляра в конечном автомате, но это не слишком трудно. Как и можно было предположить,
все сложности касаются выражений await.
Затем при вызове продолжения необходимо перейти в правильную точку, извлечь объект ожи-
дания и сбросить состояние перед продолжением.
В качестве примера возьмем первое выражение await в листинге 15.11:
await Task.Delay(unicode);
Если бы ожидание было организовано для операции, возвращающей значение, например, при-
сваивание результата await client.GetStringAsync(...) с использованием HttpClient,
то вызов GetResult() ближе к концу будет местом получения значения.
Метод AwaitUnsafeOnCompleted() присоединяет продолжение к объекту ожидания, и опе-
ратор switch в начале метода MoveNext() обеспечит при повторном выполнении MoveNext()
передачу управления методу DemoAwaitContinuation().
Ранее был показан значимый набор интерфейсов, где IAwaiter<T> расширял INotifyComple-
tion с его методом OnCompleted(). Имеется также интерфейс ICriticalNotifyCompletion
с методом UnsafeOnCompleted(). Конечный автомат вызывает builder.AwaitUnsafeOnCom-
pleted() для объектов ожидания, реализующих ICriticalNotifyCompletion, или builder.
AwaitOnCompleted() для объектов ожидания, которые реализуют только INotifyCompletion.
Мы рассмотрим отличия между этими двумя вызовами в разделе 15.6.4, когда будем обсуждать,
каким образом шаблон ожидания взаимодействует с контекстами.
ременных, подобных итератору для цикла foreach, но это не все, что помещается в стек, во
всяком случае, логически10 . В разнообразных ситуациях существуют промежуточные выражения,
которые не могут применяться до тех пор, пока не будут оценены какие-то другие выражения.
Простейшими примерами являются бинарные операции вроде сложения и вызовы методов.
Ниже приведен тривиальный пример:
var х = у * z;
С помощью псевдокода, основанного на стеке, его можно представить следующим образом:
push у
push z
multiply
store х
Теперь предположим, что есть такое выражение await:
var х = у * await z;
Перед ожиданием z необходимо оценить переменную у и сохранить где-то ее значение, но,
в конечном счете, также может произойти немедленный возврат из MoveNext(), поэтому для
хранения у нужен логический стек. Когда выполняется продолжение, значение восстанавливается
и участвует в умножении. В данном случае компилятор может присвоить значение у переменной
экземпляра stack. При этом задействуется упаковка, но это означает использование одиночной
переменной.
Рассмотренный пример был простым. А теперь представим ситуацию, при которой должно быть
сохранено несколько переменных:
Console.WriteLine("{0}: {1}", х, await task);
Здесь необходимо, чтобы в логическом стеке находилась и строка формата, и значение пере-
менной х. На этот раз компилятор создает экземпляр Tuple<string, int>, содержащий два
значения, и сохраняет ссылку на него в поле stack. Подобно объекту ожидания, в любой мо-
мент времени необходим только один логический стек, поэтому вполне нормально применять ту
же самую переменную11 . Внутри продолжения отдельные аргументы могут быть извлечены из
кортежа и использованы в вызове метода. В загружаемом коде содержится полная декомпилиро-
ванная версия этого примера, с обоими предшествующими операторами (LogicalStack.cs и
LogicalStackDecompiled.cs).
В итоге второй оператор использует примерно такой код:
string localArg0 = "{0} {1}";
int localArgl = x;
localAwaiter = task.GetAwaiter();
if (localAwaiter.IsCompleted)
{
goto SecondAwaitCompletion;
}
var localTuple = new Tuple<string, int>(localArg0, localArg1);
10
Как любит говорить Эрик Липперт, стек является деталью реализации — определенные переменные, которые вы
можете ожидать увидеть в стеке, в действительности находятся в куче, а некоторые переменные могут существовать
только в регистрах. В этом разделе речь пойдет лишь о том, что логически происходит в стеке.
11
Правда, бывают случаи, когда компилятор может быть лучше осведомлен о типе переменной либо вообще избегать
ее включения, если она никогда не понадобится, но все это может быть добавлено в более поздней версии в качестве
дополнительной оптимизации.
Глава 15. Асинхронность с помощью async/await 536
stack = localTuple;
state = 1;
awaiter = localAwaiter;
builder.AwaitUnsafeOnCompleted(ref awaiter, ref this);
doFinallyBodies = false;
return;
SecondAwaitContinuation:
localTuple = (Tuple<string, int>) stack;
localArg0 = localTuple.Item1;
localArg1 = localTuple.Item2;
stack = null;
localAwaiter = awaiter;
awaiter = default(TaskAwaiter<int>);
state = -1;
SecondAwaitCompletion:
int localArg2 = localAwaiter.GetResult();
Console.WriteLine(localArg0, localArg1, localArg2);
ный вывод компилятора С#. Это не так уже плохо, когда вы знаете, что искать, однако означает,
что иногда придется опускаться на уровень кода IL.
По соглашениям ТАР можно было бы предоставить любой или все перечисленные ниже методы:
Здесь IProgress<int> мог бы быть IProgress<T> для любого типа Т, который подходит
для использования при сообщении о ходе работ. Например, если асинхронный метод находит кол-
лекцию записей и затем обрабатывает их по одной, можно было бы принимать IProgress<Tuple
<int, int>>, который позволил бы сообщать, сколько всего записей и сколько из них обрабо-
тано.
Я бы избегал попыток втиснуть сообщение о ходе работ внутрь операций, в которых это не име-
ет никакого смысла. Отмена обычно проще в плане поддержки, поскольку ее уже поддерживают
очень многие методы инфраструктуры. Если асинхронный метод в основном состоит из выполне-
ния множества других асинхронных операций (возможно с зависимостями), то может оказаться
проще принимать признак отмены и передавать его дальше.
Асинхронные операции должны выполнять проверки на предмет ошибочного применения (обыч-
но ситуации с недопустимыми аргументами) синхронным образом. Это слегка неуклюже, но может
быть реализовано либо за счет разделения метода, как было показано в разделе 15.3.6, либо с
помощью единственного метода, использующего анонимную асинхронную функцию, как объяс-
нялось в разделе 15.4. Хотя заманчиво проверять допустимость аргументов ленивым образом, вы
поплатитесь тем, что выяснить причины возможного отказа окажется труднее, чем должно быть.
Операции, основанные на вводе-выводе, когда работа передается либо диску, либо другому ком-
пьютеру, являются великолепными кандидатами для асинхронности безо всяких видимых недо-
статков. Задачи, интенсивно загружающие центральный процессор, для этого подходят меньше.
Определенную работу легко выгрузить в пул потоков, и благодаря методу Task.Run() в .NET
4.5 это делать даже проще, чем было ранее, но выполнение такого действия внутри библиотечного
кода означало бы принятие предположений от имени вызывающего кода. Разный вызывающий код
может также иметь отличающиеся требования; если вы просто откроете доступ к синхронному
Глава 15. Асинхронность с помощью async/await 539
методу, то тем самым предоставите вызывающему коду гибкость в плане работы наиболее подхо-
дящим для него способом. Он может либо запустить новую задачу, если это необходимо, либо
вызвать метод синхронно, если вполне приемлемо загрузить текущий поток выполнением метода
в течение некоторого времени. Задачи, которые являются смесью ожидания результатов из других
систем и последующей их обработки с потенциально большими затратами времени, будут слож-
нее. Хотя я считаю, что жесткие и быстрые руководящие принципы вряд ли окажутся полезными,
важно документировать поведение. Если вы собираетесь занимать много ресурсов центрального
процессора в контексте вызывающего кода, то должны сделать это предельно ясным.
Другой способ избежать применения контекста вызывающего кода предполагает использова-
ние метода Task.ConfigureAwait(). Этот метод в настоящее время имеет только один пара-
метр, continueOnCapturedContext, хотя для ясности при его указании полезно применять
именованный аргумент. Метод возвращает реализацию шаблона ожидания. Когда аргумент равен
true, объект, поддерживающий ожидание, ведет себя в точности как нормальный объект, так что
если асинхронный метод вызывается в потоке пользовательского интерфейса, например, то про-
должение после выражения await будет выполняться по-прежнему в потоке пользовательского
интерфейса. Это удобно, если вам необходим доступ к элементам пользовательского интерфейса.
Тем не менее, если специальные требования отсутствуют, для аргумента можно указать значение
false, и тогда продолжение будет выполняться обычно в том же контексте, что и завершенная
первоначальная операция13 .
Для смешанной рабочей нагрузки, при которой определенные данные извлекаются, обрабаты-
ваются и затем сохраняются в базе данных, может быть предусмотрен код следующего вида:
Большая часть кода метода пригодна для выполнения в потоке из пула; это именно то, что нужно,
т.к. не делается ничего такого, что требует выполнения в исходном потоке. (Выражаясь профес-
сиональной лексикой, операция не имеет никакой привязки к потоку.) Однако это не влияет на
вызывающий код; если асинхронный метод пользовательского интерфейса ожидает результата вы-
зова метода ProcessRecords(), то этот асинхронный метод будет выполняться по-прежнему в
потоке пользовательского интерфейса. Только код внутри метода ProcessRecords() заявляет,
что его не заботит контекст выполнения.
Вероятно, здесь нет действительной необходимости вызывать метод ConfigureAwait() во
втором выражении await, т.к. осталось очень мало работы, но, в общем, он должен использо-
ваться в каждом выражении await, и неплохо, чтобы делать это согласованно вошло в привычку.
Если вы хотите предоставить вызывающему коду гибкость в отношении контекста, в котором ме-
тод выполняется, потенциально можно было бы предусмотреть для этого параметр в асинхронном
методе.
Обратите внимание, что метод ConfigureAwait() влияет только на часть синхронизации
контекста выполнения. Другие аспекты, такие как заимствование прав, распространяются безот-
13
Как правило, но не всегда. Детали не документированы явно, но существуют случаи, когда выполнение в том же
самом контексте на самом деле нежелательно. Вы должны считать, что вызов ConfigureAwait(false) говорит
“меня не заботит, где выполняется продолжение”, а не явно присоединять его к специфичному контексту.
Глава 15. Асинхронность с помощью async/await 540
Хотя ТАР — это просто набор соглашений и ряд примеров, в Microsoft также создали отдель-
ную библиотеку под названием TPL Dataflow (Поток данных TPL), которая предназначена для
предоставления высокоуровневых строительных блоков, ориентированных на специфичные сце-
нарии, особенно на те, которые могут быть смоделированы с применением шаблонов “произво-
дитель/потребитель”. Чтобы приступить к ее использованию, проще всего загрузить пакет NuGet
(Microsoft.Tpl.Dataflow). Он бесплатен и по нему доступно много инструкций. Даже если вы
не планируете работать с этой библиотекой напрямую, полезно взглянуть на нее, чтобы получить
представление о том, как параллельные программы в принципе могут быть спроектированы.
В качестве примера давайте рассмотрим задачу извлечения множества URL. В разделе 15.3.6
это делалось по одному URL за раз, с остановом в случае успеха. Предположим, что на этот раз
необходимо запускать запросы параллельно и затем фиксировать в журнале результат для каждого
URL. Вспомнив, что асинхронные методы возвращают уже выполняющиеся задачи, запустить
задачу для каждого URL довольно легко:
Обратите внимание, что обращение к ToList() требуется для активизации запроса LINQ.
Это гарантирует, что каждая задача запускается один и только один раз — иначе при каждом
проходе по tasks будет начинаться другой набор извлечений. (Код мог быть еще проще, если не
заботиться об освобождении HttpClient, но даже с учетом этого недостатка он выглядит весьма
неплохо.)
Библиотека TPL предлагает метод Task.WhenAll(), объединяющий результаты множества
задач, каждая из которых предоставляет одиночный результат, в единую задачу с множеством
результатов. Сигнатура перегруженной версии, которая будет применяться, выглядит следующим
образом:
Здесь будет организовано ожидание завершения всех задач и сбор результатов в массив. В
случае если несколько задач сгенерируют исключения, только первое из них будет сгенерировано
непосредственно, однако всегда можно пройти по задачам и выяснить, какие из них отказали
и почему, или применить расширяющий метод WithAggregatedExceptions(), показанный в
листинге 15.2.
Если вас интересует только первый запрос, возвращенный обратно, то есть еще один метод по
имени Task.WhenAny(), который не ожидает первого успешного завершения задачи, а ожидает
первой задачи, достигшей терминального состояния.
В данном случае может понадобиться что-то слегка отличающееся. Более удобно сообщать все
результаты, когда они поступают.
Код в листинге 15.12 полагается на очень важный тип в библиотеке TPL — TaskCompletion
Source<T>. Данный тип позволяет создавать экземпляр Task с пока отсутствующим резуль-
татом и затем позже предоставлять результат (либо исключение). Это построено на основе той
же самой инфраструктуры, которую структура AsyncTaskMethodBuilder<T> использует для
предоставления экземпляра Task, возвращаемого асинхронным методом, делая возможным запол-
нение задачи результатом, когда тело метода завершается.
Чтобы объяснить несколько необычные имена переменных, я часто думаю о задачах как о
картонных коробках, внутри которых в какой-то момент появится значение (или отказ). Тип
TaskCompletionSource<T> подобен коробке с отверстием на задней стенке — ее можно дать
кому-то и затем позже исподтишка подсмотреть значение через отверстие14 . Именно это делает ме-
тод PropagateResult() — он не особенно интересен, поэтому здесь не показан, но, в сущности,
данный метод передает результат завершенной задачи Task<T> в TaskCompletionSource<T>.
Если исходная задача завершилась нормально, то в источник завершения задачи копируется воз-
вращаемое значение. Если исходная задача потерпела отказ, то в источник завершения задачи
копируется исключение. Если же исходная задача была отменена, то отменяется и источник за-
вершения задачи.
Действительно искусной частью (и моей заслуги здесь нет — данное предложение пришло
ко мне по электронной почте) является то, что когда этот метод выполняется, ему не извест-
но соответствие между TaskCompletionSource<T> и входными задачами. Взамен он просто
присоединяет к каждой задаче одно и то же продолжение, которое выражает такое действие:
“Найти следующий источник TaskCompletionSource<T> (атомарно инкрементируя счетчик) и
передать ему результат”. Другими словами, коробки заполняются в выходном порядке по мере
завершения исходных задач.
На рис. 15.5 показаны три входных задачи и соответствующие выходные задачи, возвращаемые
методом. Выходные задачи завершаются в порядке возвращения, несмотря на то, что входные
задачи завершаются в другом порядке.
Имея этот замечательный расширяющий метод, можно написать код, приведенный в листинге
15.13, который принимает коллекцию URL, запускает запросы для каждого из URL параллельно,
выводит на консоль длину каждой страницы по мере завершения запросов и возвращает суммар-
ную длину.
14
Любые совпадения с реальностью чисто случайны, и я не несу никакой ответственности за эксперименты с
кошками (представленными с помощью Task<Cat>).
Глава 15. Асинхронность с помощью async/await 543
Момент 0:
результаты пока Входные задачи
отсутствуют
Выходные задачи
Выходные задачи 5 10
• Если одна задача терпит отказ, то отказывает вся асинхронная операция, никак не отражая
остальные результаты. Это может быть приемлемым или может понадобиться регистрировать
в журнале каждый отказ. (В отличие от .NET 4, разрешение исключениям из задач оста-
ваться необнаруженными по умолчанию не нарушает работу процесса, но вы должны хотя
бы подумать о том, что произойдет с другими задачами.)
Обе проблемы довольно легко устраняются за счет написания незначительного объема допол-
нительного кода, и они могут даже предложить новые многократно используемые строительные
блоки. Целью показанных примеров было не исследование отдельных требований, а демонстрация
возможностей, предлагаемых объединением.
Метод Interleaved() — не единственный пример в официальной документации ТАР; она
содержит множество идей и примеров кода, помогающих понять их.
В этом разделе будет представлен подход для ситуаций, когда имеется возможность управлять
асинхронными операциями, от которых зависит асинхронный код. Он не пытается разрешить
трудности тестирования кода, в котором применяется HttpClient и аналогичные трудные для
имитации типы, но в нем нет ничего нового — если есть зависимости, которые трудно использовать
в тестах, всегда будут возникать проблемы.
Предположим, что нужно протестировать код “магического упорядочения” из предыдущего
раздела. Вам необходима возможность создавать задачи, которые будут завершаться в указанном
порядке, и (по крайней мере, в некоторых тестах) удостоверяться в том, что молено устанавливать
утверждения между завершениями задач. Кроме того, все это желательно делать без привлечения
любых других потоков — требуется максимально высокая степень контроля и предсказуемости.
По сути, вы хотите иметь возможность контролировать время.
Мое решение данной проблемы, в сущности, сводится к имитации времени с применением
класса TimeMachine, который предлагает способ программного продвижения во времени с за-
планированными задачами, которые завершаются определенным образом в указанные моменты
времени. За счет объединения его с классом SynchronizationContext, который фактически
представляет собой ручную версию знакомого конвейера обработки сообщений Windows Forms,
получается довольно рациональное средство тестирования. Я не буду здесь приводить весь ис-
пользуемый для этого код инфраструктуры, т.к. он очень длинный и относительно скучный, но
полный код доступен в загружаемых примерах. Тем не менее, я покажу пару тестов.
Давайте начнем с полностью успешного случая: если вы запрограммировали три задачи для
завершения в моменты времени 1, 2 и 3, но вызвали метод InCompletionOrder() с этими
задачами в другом порядке, то должны получить результаты, которые по-прежнему упорядочены:
Глава 15. Асинхронность с помощью async/await 545
[TestMethod]
public void TasksCompleteInOrder()
{
var tardis = new TimeMachine();
var task1 = tardis.ScheduleSuccess(1, "t1");
var task2 = tardis.ScheduleSuccess(2, "t2");
var task3 = tardis.ScheduleSuccess(3, "t3");
var tasksOutOfOrder = new[] { task2, task3, task1 };
tardis.ExecuteInContext(advancer =>
{
var inOrder = tasksOutOfOrder.InCompletionOrder().ToList();
advancer.AdvanceTo(3);
Assert.AreEqual("t1", inOrder[0].Result);
Assert.AreEqual("t2", inOrder[1].Result);
Assert.AreEqual("t3", inOrder[2].Result);
});
}
Метод ExecuteInContext() временно заменяет объект SynchronizationContext теку-
щего потока объектом ManuallyPumpedSynchronizationContext (ищите этот класс в за-
гружаемом коде) и затем предоставляет объект продвижения (advancer) делегату, указанному
аргументом метода. Этот объект продвижения может применяться для продвижения времени на
заданные промежутки с задачами, завершающимися (и выполняющими продолжения) в подходя-
щие моменты времени. В этом тесте производится просто быстрый переход вперед до тех пор,
пока все задачи не будут завершены.
Ниже приведен второй тест, который демонстрирует возможность управления временем более
детализированным путем:
// Шаги по настройке не показаны; они такие же, как в предыдущем тесте.
tardis.ExecuteInContext(advancer =>
{
var inOrder = tasksOutOfOrder.InCompletionOrder().ToList();
Assert.AreEqual(TaskStatus.WaitingForActivation, inOrder[0].Status);
Assert.AreEqual(TaskStatus.WaitingForActivation, inOrder[1].Status);
Assert.AreEqual(TaskStatus.WaitingForActivation, inOrder[2].Status);
advancer.Advance();
Assert.AreEqual(TaskStatus.RanToCompletion, inOrder[0].Status);
Assert.AreEqual(TaskStatus.WaitingForActivation, inOrder[1].Status);
Assert.AreEqual(TaskStatus.WaitingForActivation, inOrder[2].Status);
advancer.Advance();
Assert.AreEqual(TaskStatus.RanToCompletion, inOrder[1].Status);
Assert.AreEqual(TaskStatus.WaitingForActivation, inOrder[2].Status);
advancer.Advance();
Assert.AreEqual(TaskStatus-RanToCompletion, inOrder[2].Status);
});
Здесь можно увидеть, что выходные задачи завершаются в правильном порядке.
Вас может заинтересовать, почему моменты времени представлены обычными целыми числа-
ми — возможно, вы ожидали, что будут задействованы типы DateTime и TimeSpan. Это сделано
умышленно — единственная временная шкала, которая на самом деле имеется, является искус-
ственной и устанавливается классом TimeMachine, а интересующими точками во времени будут
только те, где задачи завершаются.
Глава 15. Асинхронность с помощью async/await 546
Тесты в предыдущем разделе были полностью синхронными. Внутри самих этих тестов ключе-
вые слова async или await вообще не использовались. В случае применения класса TimeMachine
для всех тестов это вполне обоснованно, но в других ситуациях может понадобиться написать те-
стовые методы, декорированные с помощью async.
Это делается легко:
[Test]
public async Task GoodTestMethod()
{
// Код, в котором используется await
}
Глава 15. Асинхронность с помощью async/await 547
Теперь инфраструктуре тестирования намного легче узнать, когда тесты завершены и необ-
ходимо выполнять проверки на предмет сбоя. Подход имеет дополнительное преимущество того,
что инфраструктуры тестирования, которые не поддерживают асинхронные тесты, могут даже не
пытаться запускать их, взамен выдавая предупреждение, что намного лучше, чем некорректное
выполнение тестов. На момент написания этих строк все последние версии инфраструктур NUnit,
xUnit и Visual Studio Unit Testing Framework (также неформально называемой MS Test) поддер-
живали асинхронные тесты — другие инфраструктуры тоже могут делать это. Прежде чем при-
ступать к написанию тестов подобного рода, обязательно проверяйте конкретную инфраструктуру
и ее версию.
Вы должны также проявлять осторожность, памятуя о возможных взаимоблокировках. В от-
личие от тестов с помощью класса TimeMachine из предыдущего раздела, вряд ли вы захотите,
чтобы все продолжения выполнялись в единственном потоке, если только этот поток также не бу-
дет подкачиваться сообщениями как поток пользовательского интерфейса. Иногда вы управляете
всеми задействованными задачами и можете обосновать свой способ использования однопоточного
контекста, а в других ситуациях вы должны быть гораздо более осторожны и также обеспечить
для множества потоков возможность запускать продолжения, при условии, что сам тестирующий
код не выполняется параллельно. Я переживаю в этом случае за модульные тесты, но если вы при-
меняете тот же самый вид инфраструктуры для функциональных тестов, интеграционных тестов
или даже зондирования производственной версии, то обычно хотите, чтобы тесты запускались в
отношении реальных задач, а не имитаций, предоставляемых классом TimeMachine.
Я уверен, что со временем сообщество разработает ряд замечательных инструментов, по-
могающих тестировать все больше и больше кода. Убежден, что значительная доля будуще-
го кода будет по своей природе асинхронной, и совершенно уверен в том, что никогда не за-
хочу писать какой-либо код без тестов. К настоящему моменту мы почти закончили с асин-
хронностью, но ранее я обещал возвратиться к показанному ранее интересному вызову метода
AwaitUnsafeOnCompleted() в сгенерированном коде.
Тем не менее, есть еще один интерфейс, который его расширяет и также находится в простран-
стве имен System.Runtime.CompilerServices:
Все причины существования этих интерфейсов имеют в своей основе контекст. В этой главе
класс SynchronizationContext уже упоминался несколько раз, и вы можете хорошо знать о
нем; это контекст синхронизации, который позволяет вызовам маршализироваться в подходящий
поток, будь он специфичным потоком из пула, одиночным потоком пользовательского интерфейса
или любым другим необходимым потоком Однако это не единственный задействованный контекст.
Их много — SecurityContextLogicalCallContext и HostExecutionContext, например.
Тем не менее, общим их предком является ExecutionContext. Он действует в качестве кон-
тейнера для всех других контекстов, и именно на нем будет сосредоточено внимание в настоящем
разделе.
Очень важно, что ExecutionContext заполняет точки с await; вам не нужно возвращать-
ся в асинхронный метод, когда задача завершена, разве только для выяснения права какого
пользователя заимствуются, если вы забыли это, например. Для заполнения контекста он дол-
жен быть захвачен во время присоединения продолжения и за тем восстановлен при выпол-
нении продолжения. Это достигается посредством методов ExecutionContext.Capture() и
ExecutionContext.Run(), соответственно.
Существуют две порции кода, которые могут выполнять такую пару действий “захват/вос-
становление”: объект ожидания и класс AsyncTaskMethodBuilder<T> (вместе с родственными
ему классами). Вы могли ожидать, что нужно просто сделать выбор в пользу одного или друго-
го и оставить их. Однако в игру вступают разнообразные компромиссы. Довольно легко забыть
заполнить контекст выполнения в объекте ожидания, так что имеет смысл реализовать его одна-
жды в коде построителя метода. С другой стороны, объект ожидания будет напрямую доступен
любому использующему его коду, поэтому вряд ли вы захотите открывать потенциальную брешь в
безопасности, надеясь, что все вызывающие методы, которые пользуются сгенерированным кодом,
предполагают наличие заполнения контекста в коде объекта ожидания. Но еще менее желатель-
но, чтобы захват и восстановление контекста выполнялись дважды, порождая избыточность. Как
разрешить эту дилемму?
Вы уже видели ответ: применение двух разных интерфейсов с тонким отличием в их смыс-
ле. Если вы реализуете шаблон ожидания, то ваш метод OnCompleted() (который является
обязательным) должен заполнять контекст выполнения. Если вы решите реализовать интер-
фейс ICriticalNotifyCompletion, то ваш метод UnsafeOnCompleted() не должен запол-
нять контекст выполнения, и должен быть декорирован атрибутом [SecurityCritical], чтобы
предотвратить его вызов в коде, не являющимся доверенным. Конечно, построители методов отно-
сятся к доверенному коду, и они заполняют контекст, поэтому тут все в порядке — вызывающий
код с частичным доверием может по-прежнему эффективно использовать ваш объект ожидания,
но потенциальные нарушители не получат возможность обойти заполнение контекста.
Я намеренно оставил этот раздел довольно кратким; я нахожу всю тему, связанную с кон-
текстами, несколько запутанной, и есть много дополнительных сложностей, которых мы даже
не касались. Если вы реализуете собственный объект ожидания, не делегируя его работу су-
ществующему объекту подобного рода (или, возможно, не нуждаясь в этом), то определен-
но должны почитать статью в блоге Стивена Тауба под названием “ExecutionContext vs
SynchronizationContext” (“Сравнение ExecutionContext и SynchronizationContext”),
доступную по адресу http://mng.bz/Ye65.
блокирующих вызовов, имеющих отношение к вводу-выводу. Как вы уже видели, типы, которые
по-прежнему находятся в CLR, обычно предоставляют асинхронные операции через Task<T>, но
в самой среде WinRT такой тип не существует. Вместо этого имеется набор интерфейсов, которые
все расширяют один ключевой интерфейс IAsyncInfo:
• IAsyncAction
• IAsyncActionWithProgress<TProgress>
• IAsyncOperation<TResult>
• IAsyncOperationWithProgress<TResult, TProgress>
Можете считать, что отличие между типами Action и типами Operation подобно отличию
между Task и Task<T> или между Action и Func: тип Action не имеет возвращаемого зна-
чения, тогда как Operation имеет. Версии WithProgress встраивают сообщение о ходе работ
внутрь одиночного типа, а не требуют перегрузки методов в IProgress<T>, как в случае шаблона
ТАР.
Детали этих интерфейсов выходят за рамки тематики данной книги, но доступно немало ресур-
сов, объясняющих их. Я советую начать со статьи в блоге Стивена Тауба под названием “Diving
deep with WinRT and await” (“Более глубокие исследования WinRT и await”), которая находится
по адресу http://mng.bz/FlTF.
В терминах поддержки этих интерфейсов в C# 5 необходимо отметить несколько важных
моментов.
• Расширяющие методы AsTask() позволяют рассматривать действие или операцию как за-
дачу, с поддержкой признаков отмены и сообщением о ходе работ через IProgress<T>.
15.7 Резюме
Надеюсь, что более сложные и глубокие разделы этой главы не помешали оценить элегантность
асинхронных средств C# 5. Возможность написания эффективного асинхронного кода в рамках
более знакомой модели выполнения является крупным шагом вперед, и я уверен в том, что он
будет способен к преобразованиям, после того, как хорошо усвоится. У меня был опыт проведе-
ния презентаций по асинхронности, когда многие разработчики очень легко запутывались, когда
впервые сталкивались с этим средством и пробовали его использовать. Оно полностью поддается
пониманию, и не допускайте, чтобы оно отталкивало вас. Надеюсь, что эта глава помогла вам по-
лучить ответы хотя бы на некоторые вопросы. Кроме того, доступен большой объем документации,
и, конечно же, многие люди на Stack Overflow готовы оказать вам помощь.
Говоря о других ресурсах, я должен подчеркнуть, что пытался здесь раскрыть в основном язы-
ковые аспекты асинхронности в соответствии с остальными главами книги. Тем не менее, кроме
знания языковых средств асинхронная разработка предполагает и многое другое, и я настоятельно
рекомендую почитать все, что удастся найти по библиотеке TPL. Даже если вы пока не можете
применять C# 5, когда вы имеете дело с .NET 4, можете начать использовать Task<T> в качестве
чистой модели для асинхронных операций. Всякий раз, когда вы стремитесь прибегнуть к низко-
уровневому методу класса Thread, подумайте о том, не может ли библиотека TPL предложить
высокоуровневую абстракцию, которая позволит достичь той же самой цели более легким путем.
Подведем итог: асинхронные функции — это краеугольный камень в C# 5. Хотя мы рассмот-
рели еще не все. Существует еще пара крошечных средств, которые я просто обязан раскрыть,
прежде чем завершить настоящее издание.
ГЛАВА 16
В этой главе...
• Заключительные размышления
c:\Users\Jon\Code\Chapter16\CallerInfoDemo.cs:21 - Main
LiesAndDamnedLies.java:-10 - Main
• Конструктор: .ctor
• Финализатор: Finalize
Имя, применяемое как часть вызова метода во время выполнения инициализатора поля, явля-
ется именем этого поля.
Существуют две ситуации, в которых информация о вызывающем компоненте не заполняет-
ся. Первая из них — инициализация атрибутов; в листинге 16.3 представлен пример атрибута,
который, как можно было ожидать, получит имя члена, к которому он был применен, но, к
сожалению, в этом случае компилятор ничего не заполняет автоматически.
Глава 16. Дополнительные средства C# 5 и заключительные размышления 554
[AttributeUsage(AttributeTargets.All)]
public class MemberDescriptionAttribute : Attribute
{
public MemberDescriptionAttribute([CallerMemberName]
string member = null)
{
Member = member;
}
public string Member { get; set; }
}
Это определенно может быть полезно. Я наблюдал ситуации, когда разработчики находили
атрибуты через рефлексию, но должны были заполнять собственную структуру данных для под-
держки отображения между именем члена и атрибутом, что могло бы делаться компилятором
автоматически.
Отказ от поддержки атрибутами динамической типизации объяснить гораздо легче. В листинге
16.4 демонстрируется разновидность применения, которая, к сожалению, не работает.
class TypeUsedDynamically
{
internal void ShowCaller([CallerMemberName] string caller = "Unknown")
{
Console.WriteLine("Called by: {0}", caller);
}
}
...
dynamic x = new TypeUsedDynamically();
x.ShowCaller();
Код из листинга 16.4 выведет на консоль только Called by: Unknown, как если бы атрибут
отсутствовал. Хотя это может показаться разочаровывающим, подумайте об альтернативе: чтобы
обеспечить работоспособность, компилятору пришлось бы внедрять имя члена, имя файла и номер
строки в каждый динамический вызов, который, возможно, требовал бы этой информации. В
целом, я думаю, что затраты перевесили бы преимущества для большинства разработчиков.
NotifyPropertyChanged("FirstValue");
}
}
}
// Другие свойства с тем же самым шаблоном
private void NotifyPropertyChanged(string propertyName)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}
В листинге 16.6 приведены только те разделы кода, которые изменились — все очень просто.
Теперь в случае изменения имени свойства компилятор будет применять новое имя. Улучшение,
конечно, не шокирующее, но, без сомнения, приятное.
Глава 16. Дополнительные средства C# 5 и заключительные размышления 557
Windows 8, мы знаем один проект, над которым они интенсивно трудятся: Roslyn. Получивший
свое имя из-за расположения офиса Эрика Липперта, когда он работал над проектом, Roslyn —
это другое название идеи “компилятора как службы”, о которой так долго говорили. Проект
Roslyn предоставит API-интерфейс, который разработчики смогут использовать для анализа кода
C# (или VB), модификации его программным образом, компиляции его в код IL и т.д. Я подо-
зреваю, что потребность в нем возникнет лишь у относительно немногих разработчиков, но те,
кому оно понадобится, будут безмерно рады, и они смогут создавать замечательные вещи для всех
остальных. Только подумайте о возможности написания собственных инструментов для рефакто-
ринга, более сложного анализа соглашений кода, генерации кода и тому подобного — и все это
с помощью API-интерфейса, спроектированного быть настолько мощным и высокопроизводитель-
ным, чтобы служить механизмом для будущих выпусков Visual Studio. Пожалуй, более важно
то, что Roslyn предоставляет команде проектировщиков C# площадку, на которой относительно
легко реализовывать новые средства. Возможно, в будущем они станут даже еще более смелыми
и амбиционными!
Хотя одну вещь я могу утверждать с достаточной степенью уверенности: я продолжу на-
слаждаться написанием, разговорами и применением C# на протяжении определенного времени
независимо от того, будет язык развиваться или нет. Мне трудно поверить, что программирование
станет менее интересным в следующем десятилетии.
Как и в предыдущих изданиях, я советую делать фантастические вещи. Напишите невероят-
но ясный код, с которым понравится работать вашим коллегам. Разработайте Будущую Крупную
Вещь в мире открытого кода. Помогайте другим разработчикам на сайте Stack Overflow. Погово-
рите в группах пользователей, на конференциях, в кругу друзей и с теми, кто готов послушать
о предмете вашего страстного увлечения. Я желаю вам большой удачи во всем, что бы вы ни
предпринимали, и надеюсь, что эта книга хотя бы немного поспособствовала достижению ваших
амбиционных целей.
ПРИЛОЖЕНИЕ А
Для полноты в это приложение включены операции, которые вы уже видели, хотя в большин-
стве случаев глава 11 содержит больше подробностей, чем представлено здесь.
Описанное поведение относится к LINQ to Objects; другие поставщики могут работать по-
другому. Для каждой операции указано, какое выполнение она применяет — отложенное или
немедленное. Если операция использует отложенное выполнение, также указывается, организует
она поток или буферизирует свои данные.
Недавно я заново реализовал LINQ to Objects в проекте под названием Edulinq и докумен-
тировал каждую отдельную операцию, описав возможности оптимизации, ленивой оценки и т.д.
Дополнительные детали по LINQ to Objects можно узнать на домашней странице проекта Edulinq
по адресу http://edulinq.googlecode.com.
А.1 Агрегирование
Все операции агрегирования (табл. А.1) в результате дают одиночное значение, а не последова-
тельность. Операции Average() и Sum() работают либо с последовательностью чисел (любого
встроенного числового типа), либо с последовательностью элементов и делегатом для преобразо-
вания каждого элемента в один из встроенных числовых типов. Операции Min() и Мах() имеют
перегруженные версии для числовых типов, но могут также работать с любыми последователь-
ностями, либо применяя стандартный компаратор для типа элементов, либо используя делегат
для преобразования. Операции Count() и LongCount() являются эквивалентными друг другу
и просто имеют разные возвращаемые типы. Для обеих определены перегруженные версии — од-
на просто подсчитывает длину последовательности, а другая получает предикат и подсчитывает
только элементы, удовлетворяющие этому предикату.
Приложение А. Стандартные операции запросов LINQ 560
Выражение Результат
numbers.Sum() 10
numbers.Count() 5
numbers.Average() 2
numbers.Aggregate("seed", "SEED01234"
(current, item) =>
current + item,
result => result.ToUpper())
Самая общая операция агрегирования (показанная в нижней строке табл. А.1) называет-
ся Aggregate(). Все другие операции агрегирования могут быть выражены в форме вызовов
Aggregate(), хотя это требует относительно больших усилий. Базовая идея заключается в том,
что всегда есть “результат до настоящего времени”, стартующий с исходного начального значения.
Делегат агрегирования применяется к каждому элементу входной последовательности; делегат по-
лучает результат до настоящего времени и входной элемент и производит следующий результат.
В качестве финального дополнительного шага применяется преобразование из результата агреги-
рования в возвращаемое значение метода. При необходимости данное преобразование может дать
в результате другой тип. Это не настолько сложно, как может показаться, но вряд ли вы будете
использовать его часто.
Все операции агрегирования применяют немедленное выполнение. Перегруженная версия
Count(), которая не использует предикат, оптимизирована для реализации интерфейсов IColle-
ction и ICollection<T>; в этой ситуации она будет применять свойство Count коллекции, не
читая какие-либо данные1 .
А.2 Конкатенация
Существует единственная операция конкатенации: Concat() (табл. А.2). Как и можно было
ожидать, она работает с двумя последовательностями и возвращает одну последовательность,
состоящую из всех элементов первой последовательности, за которыми следуют все элементы
второй последовательности. Две входных последовательности должны быть одного и того же
типа, выполнение является отложенным, а все данные организуются в поток.
Таблица А.2. Пример Concat()
Выражение Результат
numbers.Concat(new[] 2, 3, 4, 5, 6) 0, 1, 2, 3, 4, 2, 3, 4, 5, 6
1
Такого сокращения для LongCount() не предусмотрено. Лично я никогда не видел, чтобы этот метод использо-
вался в LINQ to Objects.
Приложение А. Стандартные операции запросов LINQ 561
А.3 Преобразование
Операции преобразования используются довольно широко, но встречаются парами. В примерах
в табл. А.3 применяются две дополнительных последовательности для демонстрации операций
Cast() и OfТуре():
object[] allStrings = {"These", "are", "all", "strings"};
object[] notAllStrings = {"Number", "at", "the", "end", 5};
Выражение Результат
allStrings.Cast<string>() "These", "are", "all", "strings"
(как IEnumerable<string>)
Операции ToArray() и ToList не требуют особых пояснений: они читают целую последо-
вательность в память и возвращают ее либо в виде массива, либо как List<T>. Обе операции
используют немедленное выполнение.
Операции Cast() и OfType() преобразуют нетипизированную последовательность в ти-
пизированную, либо генерируя исключение (Cast()), либо игнорируя (OfType()) элементы
входной последовательности, которые не могут быть неявно преобразованы в тип элементов
Приложение А. Стандартные операции запросов LINQ 562
Второе выражение запроса приводит к тому, что типом на этапе компиляции источника ста-
новится IEnumerable<User> вместо IQueryable<User>, поэтому вся обработка происходит в
памяти, а не в базе данных. Компилятор будет использовать расширяющие методы Enumerable
(принимающие параметры в виде делегатов) вместо таких методов Queryable (принимающих
параметры в виде деревьев выражений). Обычно желательно выполнять как можно больше об-
работки в SQL, но когда есть трансформации, требующие локального кода, иногда приходится
заставлять LINQ применять подходящие расширяющие методы Enumerable. Разумеется, это не
Приложение А. Стандартные операции запросов LINQ 563
является специфичным для баз данных; прием с вынуждением заключительной части запроса
использовать Enumerable применим также и к другим поставщикам, если они основаны на
IQueryable или чем-то аналогичном.
Выражение Результат
words.ЕlementAt(2) "two"
words.ElementAtOrDefault(10) null
words.First() "zero"
words.FirstOrDefault null
(w =>w.Length == 10)
words.Last() "four"
words.SingleOrDefault null
(w =>w.Length == 10)
Имена операций понять легко: First() и Last() возвращают, соответственно, первый и по-
следний элементы последовательности, генерируя исключение InvalidOperationException,
если последовательность пуста. Операция Single() возвращает единственный элемент в после-
довательности, генерируя исключение, если последовательность пуста или содержит более одного
Приложение А. Стандартные операции запросов LINQ 564
А.5 Эквивалентность
Есть только одна стандартная операция эквивалентности: SequenceEqual() (табл. А.5). Она
просто поэлементно сравнивает две последовательности на предмет эквивалентности, включая
порядок. Например, последовательность 0, 1, 2, 3, 4 не эквивалентна последовательности 4, 3,
2, 1, 0. Перегруженная версия позволяет использовать при сравнении элементов специальную
реализацию IEqualityComparer<T>. Возвращаемое значение имеет булевский тип, а операция
применяет немедленное выполнение.
Выражение Результат
words.SequenceEqual True
(new[]{"zero","one","two","three","four"})
words.SequenceEqual False
(new[]{"ZERO","ONE","TWO","THREE", "FOUR"})
words.SequenceEqual True
(new[]{"ZERO","ONE","TWO","THREE","FOUR"},
StringComparer.OrdinalIgnoreCase)
И снова в LINQ to Objects не хватает трюка в плане оптимизации: если обе последователь-
ности располагают эффективным способом извлечения их счетчиков, имело бы смысл проверять,
равны ли они, прежде чем приступать к сравнению самих элементов. Текущая реализация просто
проходит по обеим последовательностям до тех пор, пока не достигнет конца или не обнаружит
неэквивалентную пару элементов.
Приложение А. Стандартные операции запросов LINQ 565
А.6 Генерация
Из всех генерирующих операций (табл. А.6) только одна действует на существующей последо-
вательности: DefaultIfEmpty(). Она возвращает либо исходную последовательность, если она
не пуста, либо последовательность с единственным элементом в противном случае. Этот элемент
обычно является стандартным значением для типа последовательности, но есть перегруженная
версия, которая позволяет указать используемое значение.
Три других генерирующих операции — это просто статические методы в Enumerable.
Все генерирующие операции применяют отложенное выполнение и организуют поток для сво-
его вывода — другими словами, они не просто заранее заполняют коллекцию и возвращают ее.
Исключением является операция Empty(), которая возвращает пустой массив нужного типа.
Пустой массив полностью неизменяем, поэтому тот же самый массив может возвращаться при
каждом вызове для того же самого типа элементов.
Выражение Результат
numbers.DefaultIfEmpty() 0, 1, 2, 3, 4
Enumerable.Range(15, 2) 15, 16
Enumerable.Repeat(25, 2) 25, 25
А.7 Группирование
Существуют две операции группирования, но одной из них является ToLookup(), которую вы
уже видели в разделе А.3 как операцию преобразования. Остается только операция GroupBy(),
которую мы исследовали в разделе 11.6.1 в форме конструкции group...by внутри выражений
запросов. Она использует отложенное выполнение, но буферизирует свои результаты: когда вы
начинаете проходить по результирующей последовательности групп, потребляется полная входная
последовательность.
Результатом операции GroupBy() является последовательность соответствующим образом ти-
пизированных элементов IGrouping<,>. Каждый элемент имеет ключ и последовательность эле-
ментов, относящихся к этому ключу. Во многих отношениях это лишь другой способ взгляда на
Приложение А. Стандартные операции запросов LINQ 566
Выражение Результат
words.GroupBy(word => word.Length) Ключ: 4; последовательность:
"zero", "four"
Ключ: 3; последовательность:
"one", "two"
Ключ: 5; последовательность:
"three"
Ключ: 5; последовательность:
"three"
// Проецировать каждую пару (ключ, группа) "4: 2", "3: 2", "5: 1"
// в строку
words.GroupBy
(word => word.Length,
(key, g) => key + ": " + g.Count())
А.8 Соединения
Есть две операции соединения, Join() и GroupJoin(), которые вы видели в разделе 11.5
при использовании, соответственно, конструкций join и join...into выражений запросов.
Приложение А. Стандартные операции запросов LINQ 567
Каждый метод принимает несколько параметров: две последовательности, селектор ключей для
каждой последовательности, проекцию для применения к каждой согласованной паре элементов и
необязательное сравнение ключей.
Для операции Join() проекция берет один элемент из каждой последовательности и строит
результат; для операции GroupJoin() проекция берет элемент из левой последовательности
и последовательность соответствующих элементов из правой последовательности. Обе операции
используют отложенное выполнение и организуют поток для левой последовательности, но читают
правую последовательность полностью, когда запрашивается первый результат.
Для примеров соединений в табл. А.8 будет сопоставляться последовательность имен (Robin,
Ruth, Bob, Emma) с последовательностью цветов (Red, Blue, Beige, Green). При этом просмат-
риваются первые символы в имени и цвете, так что, скажем, Robin будет соединяться с Red, a
Bob — с Blue и Beige.
Обратите внимание, что имени Emma не соответствует ни один из цветов — это имя отсутствует
во всех результатах первого примера, но присутствует в результате второго примера, с пустой
последовательностью цветов.
Выражение Результат
names.Join // Левая последовательность "Robin - Red",
(colors, // Правая последовательность "Ruth - Red",
name => name[0], // Левый селектор ключей "Bob - Blue"
color=> color [0], // Правый селектор ключей "Bob - Beige"
// Проекция для пар результатов
(name, color) => name + " - " + color
)
А.9 Разделение
Операции разделения либо пропускают начальную часть последовательности, возвращая толь-
ко ее остаток, либо берут только начальную часть последовательности, игнорируя остаток. В
каждом случае можно или указывать количество элементов в первой части последовательности,
или задавать условие — первая часть последовательности продолжается до тех пор, пока условие
не станет ложным. После того, как условие оказывается ложным в первый раз, оно заново не
проверяется — не играет никакой роли, что последующие элементы в последовательности могут
быть подходящими. Все операции разделения применяют отложенное выполнение и организуют
поток для своих данных.
Разделение фактически делит последовательность на две индивидуальных части, либо по пози-
ции, либо по предикату. В каждом случае, если выполнить конкатенацию результатов Таке() или
Приложение А. Стандартные операции запросов LINQ 568
Выражение Результат
words.Таке(2) "zero", "one"
А.10 Проецирование
Вы видели две операции проецирования (Select() и SelectMany()) в главе 11. Операция
Select() — это простая проекция “один к одному” из исходного элемента в результирующий эле-
мент. Операция SelectMany() используется, когда в выражении запроса присутствует несколько
конструкций from; каждый элемент в исходной последовательности применяется для генерации
новой последовательности. Обе операции проецирования (табл. А.10) используют отложенное вы-
полнение.
Таблица А.10. Примеры проецирования
Выражение Результат
words.Select(word => word.Length) 4, 3, 3, 5, 4
Выражение Результат
names.Zip(colors, (x, у) => x + "-" + у) "Robin-Red",
"Ruth-Blue",
"Bob-Beige",
"Emma-Green"
А.11 Квантификаторы
Операции квантификаторов, показанные в табл. А.12, возвращают булевское значение и ис-
пользуют немедленное выполнение.
Особенно удобной операцией, о которой часто забывают, является Any(). Если вы пытаетесь
выяснить, содержит ли последовательность какие-то элементы (или элементы, соответствующие
предикату), то намного лучше использовать source.Any(...), чем source.Count(...) >
0. Операции должны давать одинаковые результаты, но Any() может остановиться, как только
найдет первый элемент, a Count() подсчитает все элементы, даже если необходимо лишь знать,
что их количество отлично от нуля.
Перегруженная версия Contains(), которая не принимает специального сравнения, оптими-
зирована для случая, когда источник реализует ICollection<T>, делегируя работу реализации
данного интерфейса. Это означает, что Enumerable.Contains() по-прежнему будет быстрее
при вызове на HashSet<T>, например.
Приложение А. Стандартные операции запросов LINQ 570
Выражение Результат
words.All(word => word.Length > 3) false ("one" и "two" имеют точно
три буквы)
words.Contains("FOUR") false
words.Contains("FOUR", true
StringComparer.OrdinalIgnoreCase)
А.12 Фильтрация
Доступны две операции фильтрации — OfТуре() и Where(). За подробными сведениями
и примерами применения операции OfТуре() обращайтесь в раздел А.3. Операция Where()
возвращает последовательность, содержащую все элементы, которые соответствуют заданному
предикату. Она имеет перегруженную версию, позволяющую предикату учитывать индекс элемен-
та. Требование индекса довольно необычно, и конструкция where в выражениях запросов эту
перегруженную версию не использует. Операция Where() всегда применяет отложенное выпол-
нение и организует поток для своих данных. В табл. А.13 демонстрируется использование обеих
перегруженных версий.
Выражение Результат
words.Where(word => word.Length > 3) "zero", "three", "four"
Выражение Результат
abbc.Distinct() "a", "b", "c"
abbc.Intersect(cd) "c"
cd.Except(abbc) "d"
А.14 Сортировка
Все операции сортировки вы уже видели ранее: OrderBy() и OrderByDescending() предо-
ставляют первичную сортировку, a ThenBy() и ThenByDescending() обеспечивают последую-
щее упорядочение для элементов, которые не были различены в результате первичной сортировки.
В каждом случае указывается проекция из элемента в его ключ сортировки и может быть также
задано сравнение (между ключами). В отличие от ряда других алгоритмов сортировки в инфра-
структуре (таких как List<T>.Sort()), упорядочение LINQ является устойчивым — другими
словами, если два элемента рассматриваются как эквивалентные в плане своих ключей сортиров-
ки, они будут возвращаться в порядке, в котором находились в исходной последовательности.
Последняя операция сортировки — это Reverse(), которая изменяет порядок последователь-
ности на противоположный. Все операции сортировки (табл. А.15) применяют отложенное выпол-
нение и буферизуют свои данные.
Приложение А. Стандартные операции запросов LINQ 572
Выражение Результат
words.OrderBy(word => word) "four", "one", "three",
"two", "zero"
Б.1 Интерфейсы
Почти все интерфейсы, которые нужно знать, находятся в пространстве имен System.Collec-
tions.Generic. На рис. Б.1 показано, как были связаны основные интерфейсы до выхода версии
.NET 4.5; здесь также присутствует необобщенный IEnumerable в качестве корневого интерфей-
са. Чтобы излишне не усложнять диаграмму, в нее не были включены интерфейсы, предназначен-
ные только для чтения, которые появились в версии .NET 4.5.
Как вы уже видели несколько раз, наиболее фундаментальным интерфейсом обобщенной кол-
лекции является IEnumerable<T>, представляющий последовательность элементов, по которой
можно осуществлять проход. Интерфейс IEnumerable<T> позволяет запрашивать итератор типа
IEnumerator<T>.
Приложение Б. Обобщенные коллекции в .NET 574
IEnumerable
Inteface
IEnumerable<T>
Generic Interface
IEnumerable
ICollection<T>
Generic Interface
IEnumerable
IEnumerable<T>
реализация — SortedSet<T>.
Обычно при реализации функциональности вполне понятно, какой интерфейс (и даже реали-
зацию) необходимо использовать. Значительно труднее может оказаться решение о том, каким
образом открыть доступ к этой коллекции как к части API-интерфейса; чем более специфично
то, что возвращается, тем в большей степени вызывающий код сможет полагаться на дополни-
тельную функциональность, указанную данными типами. Это может упростить написание вы-
зывающего кода ценой будущей гибкости в рамках вашей реализации. Я обычно предпочитаю
применять в качестве возвращаемых типов методов и свойств интерфейсы, а не гарантировать
наличие конкретного класса реализации. Вы также должны тщательно подумать, прежде чем от-
крывать изменяемую коллекцию в API-интерфейсе, особенно если такая коллекция представляет
часть состояния объекта или типа. Как правило, предпочтительнее возвращать либо копию, либо
оболочку для коллекции, допускающую только чтение, если только основное намерение метода не
заключается в обеспечении изменения через возвращаемую коллекцию.
Б.2 Списки
Во многих отношениях списки являются простейшим и наиболее естественным типом коллек-
ций. Внутри инфраструктуры доступно множество их реализаций, обладающих разными возмож-
ностями и характеристиками производительности. Несколько популярных реализаций использу-
ются повсеместно, другие, более экзотические применяются в специализированных ситуациях.
Б.2.1 List<T>
Класс List<T> — это стандартный выбор списка в большинстве случаев. Он реализует интер-
фейс IList<T> и, следовательно, ICollection<T>, IEnumerable<T> и IEnumerable. Кроме
того, он реализует необобщенные интерфейсы ICollection и IList, выполняя при необходимо-
сти упаковку и распаковку, а также проверку типов во время выполнения, чтобы удостовериться
в том, что новые элементы всегда имеют тип, совместимый с Т.
Внутренне List<T> хранит массив и отслеживает логический размер списка и размер поддер-
живающего массива. Добавление элемента является либо простым случаем установки очередного
значения в массиве, либо (если массив уже заполнен) копированием существующего содержимого
в новый массив большего размера и затем установки в нем значения. Это означает, что опера-
ция имеет сложность O(1) или O(n) в зависимости от того, требуется ли копирование значений.
Стратегия расширения не документирована (и, следовательно, не гарантирована), но на практи-
ке всегда используется подход с удвоением размера. Это дает в результате амортизированную
сложность O(1) при добавлении элемента в конец списка; иногда она будет выше, но по мере
роста списка такое случается все реже.
Размером поддерживающего массива можно управлять явно, читая и устанавливая свойство
Capacity; метод TrimExcess() делает емкость в точности равной текущему размеру. На де-
ле необходимость в этом возникает редко, но если вам известен окончательный размер списка
при его создании, можете передать конструктору начальную емкость, избежав нежелательного
копирования.
Удаление элемента из List<T> требует копирования расположенных за ним элементов на по-
зицию назад, поэтому его сложность составляет O(n – r), где r — индекс удаляемого элемента;
устранение хвоста списка является менее дорогостоящей операцией, чем удаление его головы.
С другой стороны, попытка удалить элемент по значению, а не по индексу (Remove() вместо
RemoveAt()) приводит к выполнению операции со сложностью О(n), где бы элемент ни находил-
ся: каждый элемент должен либо проверен на предмет эквивалентности, либо перемещен.
Разнообразные методы в List<T> действуют как своего рода предшественник LINQ. Метод
Приложение Б. Обобщенные коллекции в .NET 576
ConvertAll() проецирует один список в другой; метод FindAll() фильтрует исходный спи-
сок в новый список, содержащий только значения, которые удовлетворяют указанному предикату.
Метод Sort() выполняет сортировку с применением или стандартного компаратора эквивалент-
ности для типа, или компаратора, переданного в аргументе. Однако между методами Sort() и
OrderBy() из LINQ существует крупное отличие: Sort() модифицирует содержимое исходно-
го списка, а не выдает упорядоченную копию. К тому же метод Sort() неустойчив, тогда как
OrderBy() устойчив; при использовании Sort() эквивалентные элементы в исходном списке
могут быть переупорядочены. Есть один аспект List<T>, который не поддерживается LINQ —
двоичный поиск: если список уже отсортирован подходящим для искомого значения образом, то
метод BinarySearch() будет более эффективным, чем IndexOf(), применяющий линейный
поиск1 .
Одним отчасти спорным аспектом List<T> является метод ForEach(). Он делает в точности
то, о чем говорит его имя — проходит по списку и для каждого значения выполняет делегат
(указанный в аргументе метода). Многие разработчики требовали, чтобы это было добавлено в
виде расширяющего метода для IEnumerable<Т>, но данному предложению до сих пор пока
сопротивлялись; Эрик Липперт в своем блоге описывает затруднения с философской точки зрения
(http://mng.bz/Rur2). Вызов ForEach(), используя лямбда-выражение, выглядит для меня
излишеством; с другой стороны, если у вас уже есть делегат, который необходимо выполнить для
каждого элемента, можно также поручить сделать это методу ForEach(), раз уж он доступен.
Б.2.2 Массивы
Массивы в некотором смысле представляют собой самый низкий уровень коллекций в .NET.
Все массивы унаследованы напрямую от System.Array, и они являются единственными коллек-
циями с прямой поддержкой в среде CLR. Одномерные массивы реализуют интерфейс IList<T>
(а также интерфейсы, которые он расширяет) и необобщенные интерфейсы IList и ICollection;
прямоугольные массивы поддерживают только необобщенные интерфейсы. Массивы всегда изме-
няемы в терминах своих элементов, но всегда фиксированы в терминах их размера. Все изменяе-
мые методы интерфейсов коллекций (вроде Add() и Remove()) реализованы явно и генерируют
исключение NotSupportedException.
Массивы ссылочных типов всегда ковариантны; например, существует неявное преобразование
из ссылки Stream[] в Object[] и явное преобразование в обратном направлении2 . Это озна-
чает, что изменения, вносимые в массив, должны проверяться во время выполнения — самому
массиву известен его тип, так что если вы попытаетесь сохранить ссылку, отличную от Stream,
в Stream[], предварительно преобразовав ссылку на массив в Object[], сгенерируется исклю-
чение ArrayTypeMismatchException.
С точки зрения CLR есть две разновидности массивов. Вектор — это одномерный массив с
нижней границей 0; все остальное считается массивом. Векторы работают лучше и почти всегда
применяются в С#. Массив в форме Т[][] по-прежнему является вектором, но с типом элемен-
тов Т[]; только прямоугольные массивы в С#, такие как string[10, 20], в конечном итоге
являются массивами в терминологии CLR. Создать массив с ненулевой нижней границей пря-
мо в C# невозможно — для этого необходимо использовать метод Array.CreateInstance(),
который позволяет указывать нижние границы, длины и тип элементов по отдельности. Создан-
ный одномерный массив с ненулевой нижней границей не получится успешно привести к Т[] —
компилятор разрешит приведение, но во время выполнения произойдет отказ.
Компилятор C# имеет встроенную поддержку массивов несколькими путями. Ему известно
не только то, как их создавать и индексировать, он также поддерживает их напрямую в циклах
1
Двоичный поиск обладает сложностью O(log n), а линейный поиск — O(n).
2
Хотя это слегка запутывает, но данный факт также означает наличие неявного преобразования из Stream[] в
IList<Object>, несмотря на то, что сам по себе интерфейс IList<T> инвариантен.
Приложение Б. Обобщенные коллекции в .NET 577
Б.2.3 LinkedList<T>
Когда список не является списком? Когда это связный список. Класс LinkedList<T> считает-
ся списком во многих отношениях — в частности, он представляет собой коллекцию, которая под-
держивает порядок при добавлении элементов, — однако он не реализует интерфейс IList<T>.
Причина в том, что он не подчиняется общепринятому контракту об эффективном доступе по
индексу. Это классический в вычислительной технике двухсвязный список: он поддерживает го-
ловной и хвостовой узлы, а каждый узел имеет ссылки на следующий и предыдущий узлы в
списке. Каждый узел доступен как экземпляр LinkedListNode<T>, что удобно в случае, когда
требуется реализовать вставку или удаление где-то в середине списка. Список явно поддерживает
размер, поэтому доступ к свойству Count эффективен.
Связные списки неэффективны в плане занимаемого пространства по сравнению со списками,
основанными на массивах, и они не поддерживают операции с индексами, но являются быстро-
действующими при вставке или удалении элементов в произвольных точках списка, если имеется
ссылка на узел в нужной точке. Такие операции обладают сложностью O(1), поскольку все, что
требуется — это корректировка ссылок на следующий и предыдущий узлы в окружающих узлах.
Вставка либо удаление из головы или хвоста списка представляет собой просто специальный слу-
чай, при котором всегда производится непосредственный доступ к изменяемому узлу. Проход по
списку (вперед или назад) также эффективен, т.к. сводится всего лишь к следованию по цепочке
ссылок.
Несмотря на то что LinkedList<T> реализует стандартные методы наподобие Add() (ко-
торый добавляет узел в хвост списка), я советую использовать явные методы AddFirst() и
AddLast(), делая свои намерения совершенно ясными. Существуют соответствующие методы
RemoveFirst() и RemoveLast(), а также свойства First и Last. Все они возвращают узлы
списка, а не значения этих узлов; свойства возвращают ссылку null, если список пуст.
Б.3 Словари
По сравнению со списками, варианты для словарей в инфраструктуре намного более ограни-
чены. Есть только три главных непараллельных реализации интерфейса IDictionary<TKey,
TValue>, хотя он также реализован классами ExpandoObject (как было показано в главе 14),
ConcurrentDictionary (который мы рассмотрим вместе с другими параллельными коллекция-
ми) и RouteValueDictionary (применяемый для маршрутизируемых веб-запросов, особенно в
ASP.NET MVC).
В качестве напоминания: основное назначение словаря — предоставление эффективного поиска
значения по ключу.
Несмотря на то что ключи внутри словаря должны быть уникальными, хеш-кодов это требова-
ние не касается. Вполне приемлема ситуация, что два разных ключа имеют один и тот же хеш-код;
это называется конфликтом хеш-кодов, и хотя эффективность словаря слегка снижается, он по-
прежнему функционирует корректно. Словарь даст отказ, если ключи являются изменяемыми и
меняют свои хеш-коды после того, как были вставлены в словарь. Изменяемые ключи словаря
почти всегда будут неудачным решением, но если обойтись без них не удается, то необходимо
обеспечить, чтобы они не изменялись после вставки в словарь.
Точные детали реализации хеш-таблицы не указаны и со временем могут измениться, но один
важный аспект иногда вызывает путаницу: внутри Dictionary<TKey, TValue> нет никакого
гарантированного порядка следования элементов, даже если они выглядят упорядоченными.
Если вы добавляете элементы в словарь и затем проходите по ним, они могут поступать в порядке,
в котором были вставлены, но рассчитывать на это нельзя. Эта индивидуальная особенность
реализации, просто добавляющей элементы даже без попытки сберечь упорядочение, несколько
огорчает; реализация, которая бы естественным образом нарушала порядок, возможно, вызывала
бы меньше вопросов.
Подобно List<T>, класс Dictionary<TKey, TValue> хранит свои элементы в массиве и
при необходимости расширяет его, приводя к амортизированной сложности O(1). Доступ по ключу
также имеет сложность O(1), предполагая рациональный хеш; если все ключи имеют один и тот
же хеш-код, сложность доступа станет О(n), потому что словарю придется проверять на равенство
все ключи по очереди. В большинстве практических сценариев эта проблема не возникает.
Приложение Б. Обобщенные коллекции в .NET 580
Б.4 Множества
До появления версии .NET 3.5 инфраструктура вообще не содержала открытые коллекции
для множеств. Когда разработчикам необходимо было что-нибудь для представления множества
в .NET 2.0, они обычно применяют Dictionary<,>, используя элементы множества в качестве
ключей и предоставляя фиктивные значения. Ситуация в определенной мере улучшилась благодаря
появлению HashSet<T> в .NET 3.5, а теперь в .NET 4 добавлен класс SortedSet<T> и общий
интерфейс ISet<T>.
Хотя логически интерфейс множества мог бы содержать только операции Add()/Remove()/
Contains(), в ISet<T> указано несколько других операций, предназначенных для манипули-
рования множеством (ExceptWith(), IntersectWith(), SymmetricExceptWith() и Union
With()) и для проверки разнообразных более сложных условий (SetEquals(), Overlaps(),
IsSubsetOf(), IsSupersetOf(), IsProperSubsetOf() и IsProperSupersetOf()). Пара-
метры для всех этих методов выражены в терминах интерфейса IEnumerable<T>, а не ISet<T>,
что поначалу вызывает удивление, но означает возможность взаимодействия множеств с LINQ
естественным образом.
Б.4.1 HashSet<T>
Класс HashSet<T> фактически является Dictionary<,> без значений. Он обладает теми
же самыми характеристиками производительности, и можно указывать IEqualityComparer<T>
для настройки способа сравнения элементов. Опять-таки, не следует полагаться на то, что HashSet
<T> поддерживает порядок, в котором к нему добавляются значения.
Одним дополнительным средством, предлагаемым HashSet<T>, является метод RemoveWhere(),
который удаляет любой элемент, соответствующий заданному предикату. Он позволяет сократить
множество, не беспокоясь об обычном запрете изменения коллекции во время прохода по ней.
Б.5.1 Queue<T>
Класс Queue<T> реализован с использованием кольцевого буфера: фактически он поддержи-
вает массив с индексом, запоминающим следующую позицию, куда будет добавляться элемент, и
еще одним индексом, в котором хранится следующая позиция, откуда элемент будет извлекаться.
Если индекс для добавления настигает индекс для удаления, содержимое копируется в массив
большего размера.
Класс Queue<T> предоставляет методы Enqueue() и Dequeue(), предназначенные для до-
бавления и удаления элементов; метод Рееk() позволяет просмотреть элемент, который будет
извлечен из очереди следующим, не удаляя его. Методы Dequeue() и Рееk() генерируют исклю-
чение InvalidOperationException, если вызываются на пустой очереди. Проход по очереди
выдает значения в порядке их помещения в очередь.
Б.5.2 Stack<T>
Реализация класса Stack<T> даже проще, чем Queue<T> — ее можно считать подобной
List<T>, но с методом Push(), предназначенным для добавления нового элемента в конец спис-
ка, Pop() для удаления последнего элемента и Реек() для просмотра последнего элемента без
его удаления. Опять-таки, методы Pop() и Реек() генерируют исключение InvalidOperation
Exception, когда вызываются на пустом стеке. Проход по стеку выдает значения в порядке, об-
ратном их помещению — значение, добавленное последним, выдается первым.
Приложение Б. Обобщенные коллекции в .NET 583
IEnumerable
Inteface
IEnumerable<T>
Generic Interface
IEnumerable
IReadOnlyCollection<T>
Generic Interface
IEnumerable<T>
IEnumerable
Я пока не вижу для себя особо много сценариев использования этих интерфейсов, но в буду-
щем, думаю, они станут очень важны. В конце 2012 года в Microsoft выпустили первую ознако-
мительную версию пакета NuGet для неизменяемых коллекций под названием Microsoft.Bcl.
Immutable. В блоге команды разработчиков библиотеки базовых классов можно найти больше
подробностей (http://mng.bz/Xlqd), но по существу они являются полностью неизменяемыми
коллекциями наряду с замораживаемыми (изменяемыми до тех пор, пока не будут заморожены)
коллекциями. Разумеется, если тип элементов изменяемый (такой как StringBuilder), то это
далеко не все, но меня по-прежнему волнуют все нормальные причины, по которым такая неизме-
няемость полезна.
Б.8 Резюме
Инфраструктура .NET Framework содержит богатый набор коллекций (хотя не настолько бога-
тый набор множеств). Набор постепенно разрастался наряду с остальными частями инфраструк-
туры, несмотря на то, что к настоящему времени наиболее распространенными коллекциями,
пожалуй, можно назвать только List<T> и Dictionary<TKey, TValue>.
Определенно есть структуры данных, которые могли бы быть добавлены в будущем, но всегда
должна производиться оценка преимуществ от добавления чего-либо в основную инфраструктуру
по сравнению с требуемыми для этого затратами. Возможно, вскоре мы увидим API-интерфейсы,
явно основанные на деревьях, вместо их применения в качестве деталей реализации внутри суще-
ствующих коллекций. Может быть, мы дождемся кучи на базе последовательностей Фибоначчи,
кеша со слабыми ссылками и тому подобного — но, как вы видели, задач для разработчиков уже
очень много и существует риск информационной перегрузки.
Если для проекта необходима конкретная структура данных, имеет смысл поискать в Интерне-
те ее реализацию с открытым кодом; проект Power Collections от Wintellect имеет особенно убе-
дительную историю как альтернатива встроенным коллекциям (http://powercollections.
codeplex.com). Но в большинстве случаев инфраструктура, вероятно, окажется вполне адек-
ватной для существующих потребностей. Надеюсь, что это приложение слегка расширило ваш
кругозор в плане того, что доступно в готовом виде в рамках инфраструктуры.
ПРИЛОЖЕНИЕ В
Полезно отметить, что если вы укажете в качестве целевой платформы .NET 2.0 (выбрать
.NET 1.0 или .NET 1.1 нельзя) в Visual Studio 2008 или Visual Studio 2010, то фактически будете
направлены на соответствующий пакет обновлений (2.0 SP1 или 2.0 SP2); это означает возмож-
ность компиляции кода, в котором применяются новые средства из пакета обновлений (одним
примечательным введением была структура System.DateTimeOffset в 2.0 SP1), с последую-
щим отказом его работы на компьютере, где установлен только первоначальный выпуск .NET 2.0.
Лично я попытался бы провести модернизацию компьютеров хотя бы последним пакетом обновле-
ний, а в идеальном случае — актуальным выпуском полной инфраструктуры.
В.2.1 C# 2.0
Крупными средствами C# 2 были обобщения (глава 3), допускающие null типы (глава 4),
анонимные методы и другие улучшения, связанные с делегатами (глава 5), а также итераторные
блоки (глава 6). Кроме того, появилось несколько небольших средств: частичные типы, стати-
ческие классы, свойства с отличающимися модификаторами доступа для средств получения и
Приложение В. Итоговые сведения по версиям 588
В.2.2 C# 3.0
Версия C# 3 главным образом ориентирована на LINQ, хотя многие средства полезны и в
других местах. Автоматические свойства, неявная типизация массивов и локальных переменных,
инициализаторы объектов и коллекций, а также анонимные типы рассматриваются в главе 8.
Лямбда-выражения и деревья выражений (глава 9) еще более увеличили прогресс, достигнутый
в области делегатов в версии 2.0, а расширяющие методы (глава 10) предоставили последний
ингредиент для выражений запросов (глава 11). Частичные методы появились только в C# 3, но
были раскрыты вместе с частичными типами в главе 7.
В.2.3 C# 4.0
Версия C# 4.0 имеет некоторые средства, способствующие взаимодействию, но не обладает
такой же целеустремленностью, как C# 3.0. Опять-таки, существует довольно ясное разделение
между мелкими средствами, показанными в главе 13 (именованные аргументы, необязательные
параметры, улучшенное взаимодействие с СОМ, обобщенная вариантность), и крупным средством
динамической типизации (глава 14).
В.2.4 C# 5.0
Версия C# 5.0 всецело ориентирована на асинхронность, описанную в главе 15, а также имеет
два других очень маленьких средства (изменения в захвате переменной в цикле foreach и
атрибуты информации о вызывающем компоненте), которые были раскрыты в главе 16. Несмотря
на то что асинхронность вводит только один новый вид выражения (await внутри функции
async), она чрезвычайно сильно изменяет модель выполнения. Я мог бы утверждать, что даже
если команда проектировщиков C# была готова предоставить другие крупные новые средства
языка (и, насколько мне известно, это так), было бы разумно попридержать их на какое-то время.
Важно, чтобы сообщество разработчиков на C# на самом деле тщательно исследовало подход
async/await, а это требует некоторого времени.
Многие области подверглись относительно небольшим обновлениям, таким как поддержка для
сжатия, получения множественных активных результирующих наборов (multiple active result set —
MARS) через одиночное подключение к SQL Server и многих статических вспомогательных мето-
дов ввода-вывода наподобие File.ReadAllText(). Возможно, справедливо будет отметить, что
модификации не были настолько значительными, как изменения, внесенные в инфраструктуры
для построения пользовательских интерфейсов.
В ASP.NET появились мастер-страницы, возможности предварительной компиляции и раз-
нообразные новые элементы управления. В Windows Forms был совершен большой скачок в
плане возможностей компоновки с помощью TableLayoutPanel и похожих классов, а также
обеспечена лучшая поддержка для улучшений производительности, таких как двойная буфери-
зация, новая модель привязки данных и развертывание ClickOnce. В .NET 2.0 появился класс
BackgroundWorker, позволяющий упростить безопасное обновление пользовательского интер-
фейса в многопоточных приложениях; строго говоря, он не является частью Windows Forms, хотя
это был его основной сценарий применения вплоть до появления Windows Presentation Foundation
в рамках версии .NET 3.0.
Из указанных четырех областей преуспели только WPF и WCF, тогда как WF и CardSpace не
получили широкого распространения. Это не говорит о том, что технологии WF и CardSpace не
используются или не обретут большую важность в будущем, но на момент написания этих строк
они применялись нечасто.
В.3.4 .NET 4
На протяжении длительного времени в библиотеки .NET 4 было вложено немало труда в той
или иной форме. Крупным добавлением стала среда DLR, а также средство Parallel Extensions,
которое кратко рассматривалось в других главах. Как обычно, технологии для построения пользо-
вательских интерфейсов получили множество усовершенствований, хотя в значительной степени
внимание было сосредоточено на изменениях для многофункциональных клиентов в WPF, а не в
Windows Forms. Существующие основные API-интерфейсы были подвергнуты многим настройкам,
направленным на облегчение работы с ними, например, метод String.Join() стал принимать
реализацию IEnumerable<T>, а не лишь строковый массив. Улучшения нельзя назвать пора-
зительными, но если они хоть немного упростят жизнь каждого разработчика, то совокупное их
влияние окажется весьма сильным. Вы уже видели, что некоторые существующие обобщенные ин-
терфейсы и делегаты стали ковариантными или контравариантными (например, IEnumerable<T>
стал IEnumerable<out Т>, a Action<T> превратился в Action<in Т>), но есть также новые
типы для ознакомления.
Появилось новое пространство имен для цифровых вычислений, System.Numeric. На время
написания этой книги оно содержало только типы BigInteger и Complex, но я не удивлюсь
добавлению в него типа BigDecimal в ближайшем будущем. В пространство имен System
включены другие новые типы, такие как Lazy<T> для лениво инициализируемых значений и
семейство обобщенных классов Tuple, которые предоставляют ту же самую функциональность,
что и класс Pair<T1, Т2> из главы 3, но принимают до восьми параметров типов. Класс Tuple
также поддерживает структурные сравнения, представляемые с помощью новых интерфейсов
IStructuralEquatable и IStructuralComparable в пространстве имен System.Collec-
tions. Хотя все классы Reactive Extensions, рассмотренные в главе 12, не находятся в .NET
4, основные интерфейсы IObserver<T> и IObservable<T> определены в пространстве имен
System.
Я привел эти специфичные элементы потому, что слишком большое внимание привлекают
новые области наподобие Managed Extensibility Framework (MEF), и такие простые типы очень
легко упустить из виду. Приятно видеть, что время уделяется всей инфраструктуре целиком, а не
только ярким нововведениям.
1
По моим личным ощущениям поддержка для такого сложного и интригующего мира, связанного с датами и вре-
менем, все еще недостаточна, поэтому я начал проект Noda Time (https://code.google.com/p/noda-time/).
Однако теперь тип TimeZoneInfo, по крайней мере, предоставляет ясный способ представления часового пояса,
отличного от местного.
Приложение В. Итоговые сведения по версиям 591
В.5.2 Silverlight
Инфраструктура Silverlight (http://silverlight.net/) предназначена для выполнения
приложений либо внутри браузеров, либо (версия Silverlight 3) в среде песочницы, обычно перво-
начально устанавливаемой из браузера. По существу он является естественным конкурентом Flash;
его очевидное преимущество в том, что он предоставляет разработчикам на C# возможность пи-
сать приложения на знакомом языке и применять знакомую библиотеку. Silverlight устанавливает
упрощенную среду CLR (называемую CoreCLR — http://mng.bz/G32M) и библиотеку клас-
сов — например, не поддерживаются необобщенные коллекции и отсутствует инфраструктура
Windows Forms. Уровень представления Silverlight основан на WPF, однако они не идентичны. Он
имеет особенно серьезную поддержку для воспроизведения медиа, с такими возможностями, как
глубокое масштабирование и адаптивное потоковое видео.
Версия Silverlight 1 была выпущена в сентябре 2007 года, хотя она ограничивалась смесью
XAML для конструирования пользовательского интерфейса и JavaScript для реализации логики.
С выходом в октябре 2008 года версии Silverlight 2 стала реальной практика доставки приложений
Silverlight, построенных с помощью С#. Некоторые средства из CoreCLR (хостинг сред CLR бок о
бок внутри одного процесса и декларативная модель безопасности, основанная на концепции про-
зрачности) теперь появились в настольной версии CLR 4.0. Она также включает раннюю версию
Dynamic Language Runtime.
Прогресс остановить было сложно, и в июле 2009 была выпущена версия Silverlight 3 с боль-
шим числом элементов управления и видеокодеков, а также автономными и внебраузерными при-
ложениями. Команда разработчиков Silverlight повторила девятимесячный цикл выпусков, предо-
ставив версию Silverlight 4 на той же неделе, что и .NET 4, с еще одним длинным списком новых
средств. Операционная система Windows Phone 7 поддерживала Silverlight 3 и некоторые средства
Silverlight 4, а затем, когда был выпущен набор Windows Phone 7.1 SDK (для поддержки телефо-
на с потребительским клеймом версии 7.5, добавляя путаницу), спектр поддерживаемых средств
Silverlight 4 расширился. Обе версии Windows Phone 7.x использовали развитие CLR из Compact
Framework.
Операционная система Windows Phone 8 поддерживает API-интерфейс Silverlight для обратной
совместимости, но также и новый API-интерфейс Windows Phone Runtime, который более близок
к API-интерфейсу WinRT, применяемому для приложений Windows Store. Кроме того, в Windows
Phone 8 используется версия среды CoreCLR, а не CLR из Compact Framework. Сама по себе
инфраструктура Silverlight теперь нежизнеспособна в плане будущего развития. Хотя я уверен, что
многие разработчики по-прежнему применяют ее, никакие новые версии выпускаться не будут. Тем
не менее, инфраструктура WinRT должна показаться разработчикам Silverlight очень знакомой. В
Microsoft старались сделать переход от приложений Silverlight на приложения Wndows Store как
можно более гладким.
В.6 Резюме
Из-за такого большого количества версий и различных компонентов очень легко запутаться —
а еще проще запутать кого-то другого. В качестве финального совета (и он действительно финаль-
ный — довольно трудно выдать что-то глубокое и значимое в предметном указателе) я рекомендую
вам стараться быть как можно более ясными при общении на эту тему с другими. Если вы не
используете ничего кроме настольной инфраструктуры, то так и скажите. Если же вы собираетесь
упомянуть номер версии, точно укажите, что имеете в виду — например, “3.0” может означать
применение C# 2.0 и .NET 3.0 либо C# 3.0 и .NET 3.5. В конце концов, после прочтения этой
книги вы не получите абсолютно никаких оправданий, утверждая о том, что используете “C#
3.5” или “C# 4.5”, если только умышленно не пытаетесь вывести меня из душевного равновесия.
Предметный указатель
B P