Делегаты, события и потоки выполнения
В этой главе рассматриваются делегаты и события — два взаимосвязанных средства языка С#, позволяющие организовать эффективное взаимодействие объектов. Во второй части главы приводятся начальные сведения о разработке многопоточных приложений.
Делегат — это вид класса, предназначенный для хранения ссылок на методы. Делегат, как и любой другой класс, можно передать в качестве параметра, а затем вызвать инкапсулированный в нем метод. Делегаты используются для поддержки событий, а также как самостоятельная конструкция языка. Рассмотрим сначала второй случай.
Описание делегатов
Описание делегата задает сигнатуру методов, которые могут быть вызваны с его помощью:
[ атрибуты ] [ спецификаторы ] delegate тип имя_делегата ( [ параметры ] )
Спецификаторы делегата имеют тот же смысл, что и для класса, причем допускаются только спецификаторы new, public, protected, internal и private. Тип описывает возвращаемое значение методов, вызываемых с помощью делегата, а необязательными параметрами делегата являются параметры этих методов. Делегат может хранить ссылки на несколько методов и вызывать их поочередно; естественно, что сигнатуры всех методов должны совпадать.
Пример описания делегата:
public delegate void D ( int i );
Здесь описан тип делегата, который может хранить ссылки на методы, возвращающие voi d и принимающие один параметр целого типа.
ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------
Делегат, как и всякий класс, представляет собой тип данных. Его базовым классом является класс System. Delegate, снабжающий своего «отпрыска» некоторыми полезными элементами, которые мы рассмотрим позже. Наследовать от делегата нельзя, да и нет смысла.
---------------------------------------------------------------------------------------------------------------------
Объявление делегата можно размещать непосредственно в пространстве имен или внутри класса.
Использование делегатов
Для того чтобы воспользоваться делегатом, необходимо создать его экземпляр и задать имена методов, на которые он будет ссылаться. При вызове экземпляра делегата вызываются все заданные в нем методы.
Делегаты применяются в основном для следующих целей:
□ получения возможности определять вызываемый метод не при компиляции, а динамически во время выполнения программы;
□ обеспечения связи между объектами по типу «источник — наблюдатель»;
□ создания универсальных методов, в которые можно передавать другие методы;
□ поддержки механизма обратных вызовов.
Все эти варианты подробно обсуждаются далее. Рассмотрим сначала пример реализации первой из этих целей. В листинге 10.1 объявляется делегат, с помощью которого один и тот же оператор используется для вызова двух разных методов
(C00l и Hack).
Использование делегата имеет тот же синтаксис, что и вызов метода. Если делегат хранит ссылки на несколько методов, они вызываются последовательно в том порядке, в котором были добавлены в делегат.
Добавление метода в список выполняется либо с помощью метода Combine, унаследованного от класса System.Delegate, либо, что удобнее, с помощью перегруженной операции сложения. Вот как выглядит измененный метод Main из предыдущего листинга, в котором одним вызовом делегата выполняется преобразование исходной строки сразу двумя методами:
При вызове последовательности методов с помощью делегата необходимо учитывать следующее:
□ сигнатура методов должна в точности соответствовать делегату;
□ методы могут быть как статическими, так и обычными методами класса;
□ каждому методу в списке передается один и тот же набор параметров;
□ если параметр передается по ссылке, изменения параметра в одном методе отразятся на его значении при вызове следующего метода;
□ если параметр передается с ключевым словом out или метод возвращает значение, результатом выполнения делегата является значение, сформированное последним из методов списка (в связи с этим рекомендуется формировать списки только из делегатов, имеющих возвращаемое значение типа void);
□ если в процессе работы метода возникло исключение, не обработанное в том же методе, последующие методы в списке не выполняются, а происходит поиск обработчиков в объемлющих делегат блоках;
□ попытка вызвать делегат, в списке которого нет ни одного метода, вызывает генерацию исключения System.NullReferenceException.
Паттерн «наблюдатель»
Рассмотрим применение делегатов для обеспечения связи между объектами по типу «источник — наблюдатель». В результате разбиения системы на множество совместно работающих классов появляется необходимость поддерживать согласованное состояние взаимосвязанных объектов. При этом желательно избежать жесткой связанности классов, так как это часто негативно сказывается на возможности многократного использования кода.
Для обеспечения гибкой, динамической связи между объектами во время выполнения программы применяется следующая стратегия. Объект, называемый источником, при изменении своего состояния, которое может представлять интерес для других объектов, посылает им уведомления. Эти объекты называются наблюдателями. Получив уведомление, наблюдатель опрашивает источник, чтобы синхронизировать с ним свое состояние.
Примером такой стратегии может служить связь объекта с различными его представлениями, например, связь электронной таблицы с созданными на ее основе диаграммами.
Программисты часто используют одну и ту же схему организации и взаимодействия объектов в разных контекстах. За такими схемами закрепилось название паттерны, или шаблоны проектирования. Описанная стратегия известна под названием паттерн «наблюдатель».
Наблюдатель (observer) определяет между объектами зависимость типа «один ко многим», так что при изменении состоянии одного объекта все зависящие от него объекты получают извещении автоматически обновляются. Рассмотрим пример (листинг 10.2), в котором демонстрируется схема оповещения источником трех наблюдателей. Гипотетическое изменение состояния объекта моделируется сообщением «OOPS!». Один из методов в демонстрационных целях сделан статическим.
В источнике объявляется экземпляр делегата, в этот экземпляр заносятся методы тех объектов, которые хотят получать уведомление об изменении состояния источника. Этот процесс называется регистрацией делегатов. При регистрации имя метода добавляется к списку. Обратите внимание: для статического метода указывается имя класса, а для обычного метода — имя объекта. При наступлении «часа X» все зарегистрированные методы поочередно вызываются через делегат.
Результат работы программы:
Для обеспечения обратной связи между наблюдателем и источником делегат объявлен с параметром типа object, через который в вызываемый метод передается ссылка на вызывающий объект. Следовательно, в вызываемом методе можно получать информацию о состоянии вызывающего объекта и посылать ему сообщения (то есть вызывать методы этого объекта).
Связь «источник — наблюдатель» устанавливается во время выполнения программы для каждого объекта по отдельности. Если наблюдатель больше не хочет получать уведомления от источника, можно удалить соответствующий метод из списка делегата с помощью метода Remove или перегруженной операции вычитания, например:
Операции
Делегаты можно сравнивать на равенство и неравенство. Два делегата равны, если они оба не содержат ссылок на методы или если они содержат ссылки на одни и те же методы в одном и том же порядке. Сравнивать можно даже делегаты различных типов при условии, что они имеют один и тот же тип возвращаемого значения и одинаковые списки параметров.
ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------
Делегаты, различающиеся только именами, считаются имеющими разные типы.
-------------------------------------------------------------------------------------------------------------------
С делегатами одного типа можно выполнять операции простого и сложного присваивания, например:
Эти операции могут понадобиться, например, в том случае, если в разных обстоятельствах требуется вызывать разные наборы и комбинации наборов методов.
Делегат, как и строка string, является неизменяемым типом данных, поэтому при любом изменении создается новый экземпляр, а старый впоследствии удаляется сборщиком мусора.
Передача делегатов в методы
Поскольку делегат является классом, его можно передавать в методы в качестве параметра. Таким образом обеспечивается функциональная параметризация: в метод можно передавать не только различные данные, но и различные функции их обработки. Функциональная параметризация применяется для создания универсальных методов и обеспечения возможности обратного вызова.
В качестве простейшего примера универсального метода можно привести метод вывода таблицы значений функции, в который передается диапазон значений аргумента, шаг его изменения и вид вычисляемой функции. Этот пример приводится далее.
Обратный вызов (callback) представляет собой вызов функции, передаваемой в другую функцию в качестве параметра. Рассмотрим рис. 10.1. Допустим, в библиотеке описана функция А, параметром которой является имя другой функции. В вызывающем коде описывается функция с требуемой сигнатурой (В) и передается в функцию А. Выполнение функции А приводит к вызову В, то есть управление передается из библиотечной функции обратно в вызывающий код.
Механизм обратного вызова широко используется в программировании. Например, он реализуется во многих стандартных функциях Windows.
Пример передачи делегата в качестве параметра приведен в листинге 10.3. Программа выводит таблицу значений функции на заданном интервале с шагом, равным единице.
В среде Visual Studio 2005, использующей версию 2.0 языка С#, можно применять упрощенный синтаксис для делегатов. Первое упрощение заключается в том, что в большинстве случаев явным образом создавать экземпляр делегата не требуется, поскольку он создается автоматически по контексту. Второе упрощение заключается в возможности создания так называемых анонимных методов — фрагментов кода, описываемых непосредственно в том месте, где используется делегат. В листинге 10.4 использованы оба упрощения для реализации тех же действий, что и листинге 10.3.
В первом случае экземпляр делегата, соответствующего функции Sin, создается автоматически. Чтобы это могло произойти, список параметров и тип возвращаемого значения функции должны быть совместимы с делегатом. Во втором случае не требуется оформлять простой фрагмент кода в виде отдельной функции Simple, как это было сделано в предыдущем листинге, — код функции оформляется как анонимный метод и встраивается прямо в место передачи.
Альтернативой использованию делегатов в качестве параметров являются виртуальные методы. Универсальный метод вывода таблицы значений функции можно реализовать с помощью абстрактного базового класса, содержащего два метода: метод вывода таблицы и абстрактный метод, задающий вид вычисляемой функции. Для вывода таблицы конкретной функции необходимо создать производный класс, переопределяющий этот абстрактный метод. Реализация метода вывода таблицы с помощью наследования и виртуальных методов приведена в листинге 10.5.
Результат работы этой программы такой же, как и предыдущей, но, на мой взгляд, в данном случае применение делегатов предпочтительнее.
Обработка исключений при вызове делегатов
Ранее говорилось о том, что если в одном из методов списка делегата генерируется исключение, следующие методы не вызываются. Этого можно избежать, если обеспечить явный перебор всех методов в проверяемом блоке и обрабатывать возникающие исключения. Все методы, заданные в экземпляре делегата, можно
получить с помощью унаследованного метода GetlnvocationList. Этот прием иллюстрирует листинг 10.6, представляющий собой измененный вариант листинга 10.1.
В этой программе помимо метода базового класса GetInvocationList использовано свойство Method. Это свойство возвращает результат типа Method Info. Класс Method Info содержит множество свойств и методов, позволяющих получить полную информацию о методе, например его спецификаторы доступа, имя и тип возвращаемого значения. Мы рассмотрим этот интересный класс в разделе «Рефлексия» главы 12.
Событие — это элемент класса, позволяющий ему посылать другим объектам уведомления об изменении своего состояния. При этом для объектов, являющихся наблюдателями события, активизируются методы-обработчики этого события. Обработчики должны быть зарегистрированы в объекте-источнике события. Таким образом, механизм событий формализует на языковом уровне паттерн «наблюдатель», который рассматривался в предыдущем разделе.
Механизм событий можно также описать с помощью модели «публикация — подписка»: один класс, являющийся отправителем (sender) сообщения, публикует события, которые он может инициировать, а другие классы, являющиеся получателями (receivers) сообщения, подписываются на получение этих событий.
События построены на основе делегатов: с помощью делегатов вызываются методы-обработчики событий. Поэтому создание события в классе состоит из следующих частей:
□ описание делегата, задающего сигнатуру обработчиков событий;
□ описание события;
□ описание метода (методов), инициирующих событие. Синтаксис события похож на синтаксис делегата
[ атрибуты ] [ спецификаторы ] event тип имя_события
Для событий применяются спецификаторы new, public, protected, internal, private, static, virtual, sealed, override, abstract и extern, которые изучались при рассмотрении методов классов. Например, так же как и методы, событие может быть статическим (static), тогда оно связано с классом в целом, или обычным — в этом случае оно связано с экземпляром класса.
Тип события — это тип делегата, на котором основано событие. Пример описания делегата и соответствующего ему события:
Обработка событий выполняется в классах-получателях сообщения. Для этого в них описываются методы-обработчики событий, сигнатура которых соответствует типу делегата. Каждый объект (не класс!), желающий получать сообщение, должен зарегистрировать в объекте-отправителе этот метод.
Как видите, это в точности тот же самый механизм, который рассматривался в предыдущем разделе. Единственное отличие состоит в том, что при использовании событий не требуется описывать метод, регистрирующий обработчики, поскольку события поддерживают операции + = и =, добавляющие обработчик в список и удаляющие его из списка.
ПРИМЕЧАНИЕ-------------------------------------------------------------------------------------------
Событие — это удобная абстракция для программиста. На самом деле оно состоит из закрытого статического класса, в котором создается экземпляр делегата, и двух методов, предназначенных для добавления и удаления обработчика из списка этого делегата.
-----------------------------------------------------------------------------------------------------------------
В листинге 10.7 приведен код из листинга 10.2, переработанный с использованием событий.
Внешний код может работать с событиями единственным образом: добавлять обработчики в список или удалять их, поскольку вне класса могут использоваться только операции += и -=. Тип результата этих операций — void, в отличие от операций сложного присваивания для арифметических типов. Иного способа доступа к списку обработчиков нет.
Внутри класса, в котором описано событие, с ним можно обращаться, как с обычным полем, имеющим тип делегата: использовать операции отношения, присваивания и т. д. Значение события по умолчанию — null. Например, в методе CryOops выполняется проверка на nul 1 для того, чтобы избежать генерации исключения
System.NullReferenceException.
В библиотеке .NET описано огромное количество стандартных делегатов, предназначенных для реализации механизма обработки событий. Большинство этих классов оформлено по одним и тем же правилам:
□ имя делегата заканчивается суффиксом EventHandler;
□ делегат получает два параметра:
О первый параметр задает источник события и имеет тип object;
О второй параметр задает аргументы события и имеет тип EventArgs или производный от него.
Если обработчикам события требуется специфическая информация о событии, то для этого создают класс, производный от стандартного класса EventArgs, и добавляют в него необходимую информацию. Если делегат не использует такую информацию, можно не описывать делегата и собственный тип аргументов, а обойтись стандартным классом делегата System.EventHandler.
Имя обработчика события принято составлять из префикса On и имени события. В листинге 10.8 приведен пример из листинга 10.7, оформленный в соответствии со стандартными соглашениями .NET. Найдите восемь отличий!
Те, кто работает с С# версии 2.0, могут упростить эту программу, используя новую возможность неявного создания делегатов при регистрации обработчиков событий. Соответствующий вариант приведен в листинге 10.9. В демонстрационных целях в код добавлен новый анонимный обработчик — еще один механизм, появившийся в новой версии языка.
События включены во многие стандартные классы .NET, например, в классы пространства имен Windows.Forms, используемые для разработки Windows-приложений. Мы рассмотрим эти классы в главе 14.
Приложение .NET состоит из одного или нескольких процессов. Процессу принадлежат выделенная для него область оперативной памяти и ресурсы. Каждый процесс может состоять из нескольких доменов (частей) приложения, ресурсы которых изолированы друг от друга. В рамках домена может быть запущено несколько потоков выполнения. Поток (thread) представляет собой часть исполняемого кода программы. В каждом процессе есть первичный поток, исполняющий роль точки входа в приложение для консольных приложений это метод Main.
Многопоточные приложения создают как для многопроцессорных, так и для однопроцессорных систем. Основной целью при этом являются повышение общей производительности и сокращение времени реакции приложения. Управление потоками осуществляет операционная система. Каждый поток получает некоторое количество квантов времени, по истечении которого управление передается другому потоку. Это создает у пользователя однопроцессорной машины впечатление одновременной работы нескольких потоков и позволяет, к примеру, выполнять ввод текста одновременно с длительной операцией по передаче данных.
Недостатки многопоточности:
□ большое количество потоков ведет к увеличению накладных расходов, связанных с их переключением, что снижает общую производительность системы;
□ в многопоточных приложениях возникают проблемы синхронизации данных, связанные с потенциальной возможностью доступа к одним и тем же данным со стороны нескольких потоков (например, если один поток начинает изменение общих данных, а отведенное ему время истекает, доступ к этим же данным может получить другой поток, который, изменяя данные, необратимо их повреждает).
Класс Thread
Поддержка многопоточности осуществляется в .NET в основном с помощью пространства имен System.Threading. Некоторые типы этого пространства описаны в табл. 10.1.
Первичный поток создается автоматически. Для запуска вторичных потоков используется класс Thread. При создании объекта-потока ему передается делегат, определяющий метод, выполнение которого выделяется в отдельный поток:
Thread t = new Thread ( new ThreadStart( имя_метода ) );
После создания потока заданный метод начинает в нем свою работу, а первичный поток продолжает выполняться. В листинге 10.10 приведен пример одновременной работы двух потоков.
В листинге используется метод Sleep, останавливающий функционирование потока на заданное количество миллисекунд. Как видите, оба потока работают одновременно. Если бы они работали с одним и тем же файлом, он был бы испорчен так же, как и приведенный вывод на консоль, поэтому такой способ распараллеливания вычислений имеет смысл только для работы с различными ресурсами.
В табл. 10.2 перечислены основные элементы класса Thread.
Можно создать несколько потоков, которые будут совместно использовать один и тот же код. Пример приведен в листинге 10.11.
Варианты вывода могут несколько различаться, поскольку один поток прерывает выполнение другого в неизвестные моменты времени.
Для того чтобы блок кода мог использоваться в каждый момент только одним потоком, применяется оператор lock. Формат оператора:
lock ( выражение ) блокоператоров
Выражение определяет объект, который требуется заблокировать. Для обычных методов в качестве выражения используется ключевое слово this, для статических — typeof (класс). Блок операторов задает критическую секцию кода, которую требуется заблокировать.
Например, блокировка операторов в приведенном ранее методе Do выглядит следующим образом:
Асинхронные делегаты
Делегат можно вызвать на выполнение либо синхронно, как во всех приведенных ранее примерах, либо асинхронно с помощью методов Begin Invoke и End Invoke. При вызове делегата с помощью метода Begin Invoke среда выполнения создает для исполнения метода отдельный поток и возвращает управление оператору, следующему за вызовом. При этом в исходном потоке можно продолжать вычисления.
Если при вызове BeginInvoke был указан метод обратного вызова, этот метод вызывается после завершения потока. Метод обратного вызова также задается с помощью делегата, при этом используется стандартный делегат AsyncCal Iback. В методе обратного вызова для получения возвращаемого значения и выходных параметров применяется метод Endlnvoke.
Если метод обратного вызова не был указан в параметрах метода Beginlnvoke, метод End Invoke можно использовать в потоке, инициировавшем запрос. В листинге 10.11 приводятся два примера асинхронного вызова метода, выполняющего разложение числа на множители. Листинг приводится по документации Visual Studio с некоторыми изменениями.
Класс Factorizer содержит метод Factorize, выполняющий разложение на множители? Этот метод асинхронно вызывается двумя способами: в методе Numl метод обратного вызова задается в Beginlnvoke, в методе Num2 имеют место ожидание завершения потока и непосредственный вызов End Invoke.
ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------
Атрибут [OneWayAttribute()] помечает метод как не имеющий возвращаемого значения и выходных параметров.
--------------------------------------------------------------------------------------------------------------------
Рекомендации по программированию
Делегаты широко применяются в библиотеке .NET как самостоятельно, так и для поддержки механизма событий, который имеет важнейшее значение при программировании под Windows.
Делегат представляет собой особый вид класса, несколько напоминающий интерфейс, но, в отличие от него, задающий только одну сигнатуру метода. В языке C++ аналогом делегата является указатель на функцию, но он не обладает безопасностью и удобством использования делегата. Благодаря делегатам становится возможной гибкая организация взаимодействия, позволяющая поддерживать согласованное состояние взаимосвязанных объектов.
Начиная с версии 2.0, в С# поддерживаются возможности, упрощающие процесс программирования с применением делегатов — неявное создание делегатов при регистрации обработчиков событий и анонимные обработчики.
Основной целью создания многопоточных приложений является повышение общей производительности программы. Однако разработка многопоточных приложений сложнее, поскольку при этом возникают проблемы синхронизации данных, связанные с потенциальной возможностью доступа к одним и тем же данным со стороны нескольких потоков.
Работа с файлами
Под файлом обычно подразумевается именованная информация на внешнем носителе, например на жестком или гибком магнитном диске. Логически файл можно представить как конечное количество последовательных байтов, поэтому такие устройства, как дисплей, клавиатура и принтер, также можно рассматривать как частные случаи файлов. Передача данных с внешнего устройства в оперативную память называется чтением, или вводом, обратный процесс — записью, или выводом.
Ввод-вывод в С# выполняется с помощью подсистемы ввода-вывода и классов библиотеки .NET. В этой главе рассматривается обмен данными с файлами и их частным случаем — консолью. Обмен данными реализуется с помощью потоков.
Поток (stream) — это абстрактное понятие, относящееся к любому переносу данных от источника к приемнику. Потоки обеспечивают надежную работу как со стандартными, так и с определенными пользователем типами данных, а также единообразный и понятный синтаксис. Поток определяется как последовательность байтов и не зависит от конкретного устройству, с которым производится обмен (оперативная память, файл на диске, клавиатура или принтер).
Обмен с потоком для повышения скорости передачи данных производится, как правило, через специальную область оперативной памяти — буфер. Буфер выделяется для каждого открытого файла. При записи в файл вся информация сначала направляется в буфер и там накапливается до тех пор, пока весь буфер не заполнится. Только после этого или после специальной команды сброса происходит передача данных на внешнее устройство. При чтении из файла данные вначале считываются в буфер, причем не столько, сколько запрашивается, а сколько помещается в буфер.
Механизм буферизации позволяет более быстро и эффективно обмениваться информацией с внешними устройствами.
Для поддержки потоков библиотека .NET содержит иерархию классов, основная часть которой представлена на рис. 11.1. Эти классы определены в пространстве имен System. 10. Помимо классов там описано большое количество перечислений для задания различных свойств и режимов.
Классы библиотеки позволяют работать в различных режимах с файлами, каталогами и областями оперативной памяти. Краткое описание классов приведено в табл. 11.1.
Как можно видеть из таблицы, выполнять обмен с внешними устройствами можно на уровне:
□ двоичного представления данных (BinaryReader, BinaryWriter);
□ байтов (FileStream);
□ текста, то есть символов (StreamWriter, StreamReader).
В .NET используется кодировка Unicode, в которой каждый символ кодируется двумя байтами. Классы, работающие с текстом, являются оболочками классов, использующих байты, и автоматически выполняют перекодирование из байтов в символы и обратно.
Двоичные и байтовые потоки хранят данные в том же виде, в котором они представлены в оперативной памяти, то есть при обмене с файлом происходит побитовое копирование информации. Двоичные файлы применяются не для просмотра их человеком, а для использования в программах.
Доступ к файлам может быть последовательным, когда очередной элемент можно прочитать (записать) только после аналогичной операции с предыдущим элементом, и произвольным, или прямым, при котором выполняется чтение (запись) произвольного элемента по заданному адресу. Текстовые файлы позволяют выполнять только последовательный доступ, в двоичных и байтовых потоках можно использовать оба метода.
Прямой доступ в сочетании с отсутствием преобразований обеспечивает высокую скорость получения нужной информации.
ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------
Методы форматированного ввода, с помощью которых можно выполнять ввод с клавиатуры или из текстового файла значений арифметических типов, в С# не поддерживаются. Для преобразования из символьного в числовое представление используются методы класса Convert или метод Parse, рассмотренные в разделе «Простейший ввод-вывод» (см. с. 59).
------------------------------------------------------------------------------------------------------------------
ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------
Форматированный вывод, то есть преобразование из внутренней формы представления числа в символьную, понятную человеку, выполняется с помощью перегруженных методов ToString, результаты выполнения которых передаются в методы текстовых файлов.
---------------------------------------------------------------------------------------------------------------------
Помимо перечисленных классов в библиотеке .NET есть классы XmlTextReader и XmlTextWriter, предназначенные для формирования и чтения кода в формате XML. Понятие об XML дается в главе 15.
Рассмотрим простейшие способы работы с файловыми потоками. Использование классов файловых потоков в программе предполагает следующие операции:
1. Создание потока и связывание его с физическим файлом.
2. Обмен (ввод-вывод).
3. Закрытие файла.
Каждый класс файловых потоков содержит несколько вариантов конструкторов, с помощью которых можно создавать объекты этих классов различными способами и в различных режимах.
Например, файлы можно открывать только для чтения, только для записи или для чтения и записи. Эти режимы доступа к файлу содержатся в перечислении FileAccess, определенном в пространстве имен System. 10. Константы перечисления приведены в табл. 11.2.
Возможные режимы открытия файла определены в перечислении FileMode (табл. 11.3).
Режим FileMode.Append можно использовать только совместно с доступом типа FileAccess.Write, то есть для файлов, открываемых для записи.
Режимы совместного использования файла различными пользователями определяет перечисление FileShare (табл. 11.4).
Ввод-вывод в файл на уровне байтов выполняется с помощью класса FileStream, который является наследником абстрактного класса Stream, определяющего набор стандартных операций с потоками. Элементы класса Stream описаны в табл. 11.5.
Класс FileStream реализует эти элементы для работы с дисковыми файлами. Для определения режимов работы с файлом используются стандартные перечисления FileMode, FileAccess и FileShare. Значения этих перечислений приведены в табл. 11.2—11.4. В листинге 11.1 представлен пример работы с файлом. В примере демонстрируются чтение и запись одного байта и массива байтов, а также позиционирование в потоке.
Результат работы программы:
100 0 1 2 3 4 5 6 7 8 9 10 9 8 7 6 0 0 0 0
4
5
Текущая позиция в потоке 7
Текущая позиция в потоке первоначально устанавливается на начало файла (для любого режима открытия, кроме Append) и сдвигается на одну позицию при записи каждого байта.
Для установки желаемой позиции чтения используется метод Seek, имеющий два параметра: первый задает смещение в байтах относительно точки отсчета, задаваемой вторым. Точки отсчета задаются константами перечисления SeekOrigin: начало файла — Begin, текущая позиция — Current и конец файла — End.
В данном примере файл создавался в текущем каталоге. Можно указать и полный путь к файлу, при этом удобнее использовать дословные литералы, речь о которых шла в разделе «Литералы» (см. с. 30), например:
FileStream f = new FileStream( @"D:\C#\test.txt",
FileMode.Create, FileAccess.ReadWrite );
В дословных литералах не требуется дублировать обратную косую черту.
Операции по открытию файлов могут завершиться неудачно, например, при ошибке в имени существующего файла или при отсутствии свободного места на диске, поэтому рекомендуется всегда контролировать результаты этих операций.
В случае непредвиденных ситуаций среда выполнения генерирует различные исключения, обработку которых следует предусмотреть в программе, например:
□ FileNotFoundException, если файла с указанным именем в указанном каталоге не существует;
□ DirectoryNotFoundException, если не существует указанный каталог;
□ ArgumentException, если неверно задан режим открытия файла;
□ IOException, если файл не открывается из-за ошибок ввода-вывода. Возможны и другие исключительные ситуации.
Удобно обрабатывать наиболее вероятные ошибки раздельно, чтобы предоставить пользователю программы в выводимом сообщении наиболее точную информацию. В приведенном далее примере отдельно перехватывается ошибка в имени файла, а затем обрабатываются все остальные возможные ошибки:
При закрытии файла освобождаются все связанные с ним ресурсы, например, для файла, открытого для записи, в файл выгружается содержимое буфера. Поэтому рекомендуется всегда закрывать файлы после окончания работы, в особенности файлы, открытые для записи. Если буфер требуется выгрузить, не закрывая файл, используется метод Flush.
Класс Stream (и, соответственно, FileStream) поддерживает два способа выполнения операций ввода-вывода: синхронный и асинхронный. По умолчанию файлы открываются в синхронном режиме, то есть последующие операторы выполняются только после завершения операций ввода-вывода. Для длительных файловых операций более эффективно выполнять ввод-вывод асинхронно, в отдельном потоке выполнения. При этом в первичном потоке можно выполнять другие операции.
Для асинхронного ввода-вывода необходимо открыть файл в асинхронном режиме, для этого используется соответствующий вариант перегруженного конструктора. Асинхронная операция ввода инициируется с помощью метода BeginRead. Помимо характеристик буфера, в который выполняется ввод, в этот метод передается делегат, задающий метод, выполняемый после завершения ввода.
Этот метод может инициировать обработку полученной информации, возобновить операцию чтения или выполнить любые другие действия, например, проверить успешность ввода и сообщить о его завершении. Обычно в этом методе вызывается метод EndRead, который завершает асинхронную операцию.
Аналогично выполняется и асинхронный вывод. В листинге 11.2 приведен пример асинхронного чтения из файла большого объема и параллельного выполнения диалога с пользователем.
ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------
Вообще говоря, существуют различные способы завершения асинхронных операций, и здесь демонстрируется только один из них.
Для удобства восприятия операции чтения из файла и диалога с пользователем оформлены в отдельный класс Demo.
Метод OnCompletedRead (оператор 1) должен получать один параметр стандартного типа IAsyncResult, содержащий сведения о завершении операции, которые передаются в метод EndRead.
Файл открывается в асинхронном режиме, об этом говорит значение true по-следнего параметра конструктора (оператор 2). В операторе 3 создается экземпляр стандартного делегата AsyncCal Iback, который инициализируется методом
OnCompletedRead.
С помощью этого делегата метод OnCompletedRead передается в метод BeginRead (оператор 4), который создает отдельный поток, начинает асинхронный ввод и возвращает управление в вызвавший поток. Обратный вызов метода OnCompletedRead происходит при завершении операции ввода. При достаточно длинном файле verybigfile можно убедиться, что приглашение к вводу в методе User Input выдается раньше, чем сообщение о завершении операции ввода из метода OnCompletedRead.
ПРИМЕЧАНИЕ ---------------------------------------------------------------------------------------------
Пример, приведенный в листинге 11.2, максимально упрощен для демонстрации методов BeginRead и EndRead, поэтому в нем нет необходимых в любой программе проверок наличия файла, успешности считывания и т. д.
Символьные потоки StreamWriter и StreamReader работают с Unicode-символами, следовательно, ими удобнее всего пользоваться для работы с файлами, предназначенными для восприятия человеком. Эти потоки являются наследниками классов TextWriter и TextReader Соответственно, которые обеспечивают их большей частью функциональности, В табл. 11.6 и 11.7 приведены наиболее важные элементы этих классов. Как видите, произвольный доступ для текстовых файлов не поддерживается.
Вы уже знакомы с некоторыми методами, приведенными в этих таблицах: на протяжении всей книги постоянно использовались методы чтения из текстовых потоков и записи в текстовые потоки, но не для дисковых файлов, а для консоли, которая является их частным случаем.
В листинге 11.3 создается текстовый файл, в который записываются две строки. Вторая строка формируется из преобразованных численных значений переменных и поясняющего текста. Содержимое файла можно посмотреть в любом текстовом редакторе. Файл создается в том же каталоге, куда среда записывает исполняемый файл. По умолчанию это каталог ...\ConsoleApplication1\bin\Debug.
В этой программе весь файл считывается за один прием с помощью метода ReadToEnd. Чаще возникает необходимость считывать файл построчно, такой пример приведен в листинге 11.5. Каждая строка при выводе предваряется номером.
Пример преобразования чисел, содержащихся в текстовом файле, в их внутреннюю форму представления приведен в листинге 11.6. В программе вычисляется сумма чисел в каждой строке.
На содержимое файла накладываются весьма строгие ограничения: числа должны быть разделены ровно одним пробелом, после последнего числа в строке пробела быть не должно, файл не должен заканчиваться символом перевода строки. Методы разбиения строки и преобразования в целочисленное представление рассматривались ранее.
Двоичные файлы хранят данные в том же виде, в котором они представлены в оперативной памяти, то есть во внутренней форме представления. Двоичные файлы применяются не для просмотра их человеком, а для использования в программах. Выходной поток BinaryWriter поддерживает произвольный доступ, то есть имеется возможность выполнять запись в»произвольную позицию двоичного файла.
Двоичный файл открывается на основе базового потока, в качестве которого чаще всего используется поток FileStream. Входной двоичный поток содержит перегруженные методы чтения для всех простых встроенных типов данных.
Основные методы двоичных потоков приведены в табл. 11.8 и 11.9.
В листинге 11.7 приведен пример формирования двоичного файла. В файл записывается последовательность вещественных чисел, а затем для демонстрации произвольного доступа третье число заменяется числом 8888.
При создании двоичного потока в него передается объект базового потока. При установке указателя текущей позиции в файле учитывается длина каждого значения типа doubi e — 8 байт.
Попытка просмотра сформированного программой файла в текстовом редакторе весьма медитативная, но не информативная, поэтому в листинге 11.8 приводится программа, которая с помощью экземпляра BinaryReader считывает содержимое файла в массив вещественных чисел, а затем выводит этот массив на экран.
При чтении принимается во внимание тот факт, что метод ReadDouble при обнаружении конца файла генерирует исключение EndOfStreamException. Поскольку в данном случае это не ошибка, тело обработчика исключений пустое.
Консольные приложения имеют весьма ограниченную область применения, самой распространенной из которых является обучение языку программирования. Для организации ввода и вывода используется известный вам класс Console, определенный в пространстве имен System. В этом классе определены три стандартных
потока: входной поток Console. In класса TextReader и выходные потоки Console.Out и Console.Error класса TextWriter.
По умолчанию входной поток связан с клавиатурой, а выходные — с экраном, однако можно перенаправить эти потоки на другие устройства с помощью методов Set In и SetOut или средствами операционной системы (перенаправление с помощью операций <, > и »).
При обмене с консолью можно применять методы указанных потоков, но чаще используются методы класса Console — Read, ReadLine, Write и WriteLine, которые просто передают управление методам нижележащих классов In, Out и Error.
Использование не одного, а двух выходных потоков полезно при желании разделить нормальный вывод программы и ее сообщения об ошибках. Например, нормальный вывод программы можно перенаправить в файл, а сообщения об ошибках — на консоль или в файл журнала.
В пространстве имен System. I0 есть четыре класса, предназначенные для работы с физическими файлами и структурой каталогов на диске: Directory, File, Directory Info и Fi1elnfo. С их помощью можно выполнять создание, удаление, перемещение файлов и каталогов, а также получение их свойств.
Классы Directory и File реализуют свои функции через статические методы. Directory Info и Fi I elnfo обладают схожими возможностями, но они реализуются путем создания объектов соответствующих классов. Классы Directorylnfo и FiIelnfo происходят от абстрактного класса FileSystemlnfo, который снабжает их базовыми свойствами, описанными в табл. 11.10.
Класс Directory Info содержит элементы, позволяющие выполнять необходимые действия с каталогами файловой системы. Эти элементы перечислены в табл. 11.11.
В листинге 11.9 приведен пример, в котором создаются два каталога, выводится информация о них и предпринимается попытка удаления каталога.
Каталог не пуст, поэтому попытка его удаления не удалась. Впрочем, если использовать перегруженный вариант метода Delete с одним параметром, задающим режим удаления, можно удалить и непустой каталог:
dil.Delete( true ); // удаляет непустой каталог
Обратите внимание на свойство Attributes. Некоторые его возможные значения, заданные в перечислении FileAttributes, приведены в табл. 11.12.
Листинг 11.10 демонстрирует использование класса Filelnfo для копирования всех файлов с расширением jpg из каталога d:\foto в каталог d:\temp. Метод Exists позволяет проверить, существует ли исходный каталог.
Использование классов Fi 1 е и D1 rectory аналогично, за исключением того, что их методы являются статическими и, следовательно, не требуют создания объектов.
Сохранение объектов (сериализация)
В С# есть возможность сохранять на внешних носителях не только данные примитивных типов, но и объекты. Сохранение объектов называется сериализацией, а восстановление сохраненных объектов — десериализацией. При сериализации объект преобразуется в линейную последовательность байтов. Это сложный процесс, поскольку объект может включать множество унаследованных полей и ссылки на вложенные объекты, которые, в свою очередь, тоже могут состоять из объектов сложной структуры.
К счастью, сериализация выполняется автоматически, достаточно просто пометить класс как сериализуемый с помощью атрибута [Serializable]. Атрибуты рассматриваются в главе 12, пока же достаточно знать, что атрибуты — это дополнительные сведения о классе, которые сохраняются в его метаданных. Те поля, которые сохранять не требуется, помечаются атрибутом [NonSerialized], например:
Объекты можно сохранять в одном из двух форматов: двоичном или SOAP (в виде XML-файла). В первом случае следует подключить к программе пространство имен System.Runtime.Serialization.Formatters.Binary, во втором — пространство System.Runtime.Serialization.Formatters.Soap.
Рассмотрим сохранение объектов в двоичном формате. Для этого используется класс BinaryFormatter, в котором определены два метода:
Serialize( поток, объект );
Deserialize( поток );
Метод Serialize сохраняет заданный объект в заданном потоке, метод Deserialize восстанавливает объект из заданного потока.
В листинге 11.11 объект приведенного ранее класса Demo сохраняется в файле на диске с именем Demo.bin. Этот файл можно просмотреть, открыв его, к примеру, в Visual Studio.NET.
В программе не приведены неиспользуемые методы и свойства классов. Обратите внимание на то, что базовые классы сохраняемых объектов также должны быть помечены как сохраняемые. Результат работы программы:
Monster Вася health = 100 ammo = 80
Monster Петя health = 120 ammo = 50
2
2 ,
Итак, для сохранения объекта в двоичном формате необходимо:
1. Подключить к программе пространство имен System.Runtime.Serialization.
Formatters.Binary.
2. Пометить сохраняемый класс и связанные с ним классы атрибутом [Serializable].
3. Создать поток и связать его с файлом на диске или с областью оперативной памяти.
4. Создать объект класса BinaryFormatter.
5. Сохранить объекты в потоке.
6. Закрыть файл.
В листинге 11.12 сохраненный объект считывается из файла.
Как видите, при сериализации сохраняется все дерево объектов. Обратите внимание на то, что значение поля у не было сохранено, поскольку оно было помечено как несохраняемое.
ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------
Сериализация в формате SOAP выполняется аналогично с помощью класса SoapFormatter. Программист может задать собственный формат сериализации, для этого ему придется реализовать в своих классах интерфейс I Serializable и специальный вид конструктора класса.
-------------------------------------------------------------------------------------------------------------------
Рекомендации по программированию
Большинство программ тем или иным образом работают с внешними устройствами, в качестве которых могут выступать, например, консоль, файл на диске или сетевое соединение. Взаимодействие с внешними устройствами организуется с помощью потоков, которые поддерживаются множеством классов библиотеки .NET.
Поток определяется как последовательность байтов и не зависит от конкретного устройства, с которым производится обмен. Классы библиотеки позволяют работать с потоками в различных режимах и на различных уровнях: на уровне двоичного представления данных, байтов и текста. Двоичные и байтовые потоки хранят данные во внутреннем представлении, текстовые в кодировке Unicode. Поток можно открыть в синхронном или асинхронном режиме для чтения, записи или добавления. Доступ к файлам может быть последовательным и произвольным Текстовые файлы позволяют выполнять только последовательный доступ, в двоичных и байтовых потоках можно использовать оба метода. Прямой доступ в сочетании с отсутствием преобразований обеспечивает высокую скорость обмена. Методы форматированного ввода для значений арифметических типов в С# не поддерживаются. Для преобразования из символьного в числовое представление используются методы класса Convert или метод Parse. Форматированный вывод выполняется с помощью перегруженного метода ToString, результат выполнения которого передается в методы текстовых файлов.
Рекомендуется всегда проверять успешность открытия существующего файла, перехватывать исключения, возникающие при преобразовании значений арифметических типов, и явным образом закрывать файл, в который выполнялась запись. Длительные операции с файлами более эффективно выполнять в асинхронном режиме.
Для сохранения объектов (сериализации) используется атрибут [Serializable]. Объекты можно сохранять в одном из двух форматов: двоичном или SOAP (в виде XML-файла).
Сборки, библиотеки, атрибуты, директивы
Все наши предыдущие приложения состояли из одного физического файла. Для больших проектов это неудобно и чаще всего невозможно, да и в других случаях бывает удобнее поместить связанные между собой типы в библиотеку и использовать их по мере необходимости. В этой главе мы рассмотрим вопросы создания и использования библиотек, способы получения и подготовки информации о типах, пространства имен и препроцессор. Эти сведения необходимы для успешной разработки реальных программ.
В результате компиляции в среде .NET создается сборка — файл с расширением ехе или dll, который содержит код на промежуточном языке, метаданные типов, манифест и ресурсы (рис. 12.1). Понятие сборки было введено в главе 1 (см. с. 9), а сейчас мы рассмотрим ее составные части более подробно.
Промежуточный язык (Intermediate Language, IL) не содержит инструкций, зависящих от операционной системы и типа компьютера, что обеспечивает две основные возможности:
□ выполнение приложения на любом типе компьютера, для которого существует среда выполнения CLR;
□ повторное использование кода, написанного на любом .NET-совместимом язык?.
IL-код можно просмотреть с помощью дизассемблера ILDasm.exe, который находится в папке ...\SDK\bin\ каталога размещения Visual Studio.NET. После запуска ILDasm можно открыть любой файл среды .NET с расширением ехе или dll с помощью команды File ► Open. В окне программы откроется список всех элементов сборки, сведения о каждом можно получить двойным щелчком. При этом открывается окно, в котором для методов выводится доступный для восприятия дизассемблированный код.
Метаданные типов ~ это сведения о типах, используемых в сборке. Компилятор создает метаданные автоматически. В них содержится информация о каждом типе, имеющемся в программе, и о каждом его элементе. Например, для каждого класса описываются все его поля, методы, свойства, события, базовые классы и интерфейсы.
Среда выполнения использует метаданные для поиска определений типов и их элементов в сборке, для создания экземпляров объектов, проверки вызова методов и т. д. Компилятор, редактор кода и средства отладки также широко используют метаданные, например, для вывода подсказок и диагностических сообщений.
Манифест — это набор метаданных о самой сборке, включая информацию обо всех файлах, входящих в состав сборки, версии сборки, а также сведения обо всех внешних сборках, на которые она ссылается. Манифест создается компилятором автоматически, программист может дополнять его собственными атрибутами.
Чаще всего сборка состоит из единственного файла, однако она может включать и несколько физических файлов (модулей). В этом случае манифест либо включается в состав одного из файлов, либо содержится в отдельном файле. Многофайловые сборки используются для ускорения загрузки приложения — это имеет смысл для сборок большого объема, работа с которыми производится удаленно.
На логическом уровне сборка представляет собой совокупность взаимосвязанных типов — классов, интерфейсов, структур, перечислений, делегатов и ресурсов. Библиотека .NET представляет собой совокупность сборок, которую используют приложения. Точно так же можно создавать и собственные сборки, которые можно будет задействовать либо в рамках одного приложения (частные сборки), либо совместно различными приложениями (открытые сборки). По умолчанию все сборки являются частными.
Манифест сборки содержит:
□ идентификатор версии;
□ список всех внутренних модулей сборки;
□ список внешних сборок, необходимых для нормального выполнения сборки;
□ информацию о естественном языке, используемом в сборке (например, русском);
□ «сильное» имя (strong name) — специальный вариант имени сборки, используемый для открытых сборок;
□ необязательную информацию, связанную с безопасностью;
□ необязательную информацию, связанную с хранением ресурсов внутри сборки (подробнее о форматах ресурсов .NET см. [27]).
Идентификатор версии относится ко всем элементам сборки. Он позволяет избегать конфликтов имен и поддерживать одновременное существование и использование различных версий одних и тех же сборок. Идентификатор версии состоит из двух частей: информационной версии в виде текстовой строки и версии , совместимости в виде четырех чисел, разделенных точками:
□ основной номер версии (major version);
□ дополнительный номер версии (minor version);
□ номер сборки (build number);
□ номер ревизии (revision number).
Среда выполнения применяет идентификатор версий для определения того, какие из открытых сборок совместимы с требованиями клиента. Например, если клиент запрашивает сборку 3.1.0.0, а присутствует только версия 3.4.0.0, сборка не будет опознана как подходящая, поскольку считается, что в дополнительных версиях могут произойти изменения в типах и их элементах. Разные номера ревизии допускают, но не гарантируют совместимость. Номер сборки на совместимость не влияет, так как чаще всего он изменяется при установке заплатки, или патча (patch).
Идентификатор версии формируется автоматически, но при желании можно задать его вручную с помощью атрибута [AssemblyVersion], который рассматривается далее на с. 285.
Информация о безопасности позволяет определить, предоставить ли клиенту доступ к запрашиваемым элементам сборки. В манифесте сборки определены ограничения системы безопасности.
Ресурсы представляют собой, например, файлы изображений, помещаемых на форму, текстовые строки, значки приложения и т. д. Хранение ресурсов внутри сборки обеспечивает их защиту и упрощает развертывание приложения. Среда Visual Studio.NET предоставляет возможности автоматического внедрения ресурсов в сборку.
Открытые и частные сборки различаются по способам размещения на компьютере пользователя, именованию и политике версий. Частные сборки должны находиться в каталоге приложения, использующего сборку, или в его подкаталогах.
Открытые сборки размещаются в специальном каталоге, который называется глобальным кэшем сборок (Global Assembly Cache, GAC). Для идентификации открытой сборки используется уже упоминавшееся сильное имя (strong name), которое должно быть уникальным.
Для создания библиотеки следует при разработке проекта в среде Visual Studio.NET выбрать шаблон Class Library (библиотека классов). В главе 8 была создана простая иерархия классов персонажей компьютерной игры. В этом разделе мы оформим ее в виде библиотеки, то есть сборки с расширением dll. Для сборки задано имя MonsterLib (рис. 12.2).
Текст модуля приведен в листинге 12.1. По сравнению с модулем из главы 8 в него добавлены спецификаторы доступа publiс для всех трех классов, входящих в библиотеку.
Скомпилировав библиотеку, вы обнаружите файл MonsterLib.dll в каталогах ...\bin\ Debug и ...\obj\Debug. Открыв файл Monsterl_ib.dll с помощью программы ILDasm.exe, можно получить полную информацию о созданной библиотеке (рис. 12.3).
Любая библиотека — это сервер, предоставляющий свои ресурсы клиентам. Создадим клиентское приложение, выполняющее те же функции, что и приложение из раздела «Виртуальные методы» (см. с. 178), но с использованием библиотеки MonsterLib.dll. Для того чтобы компилятор мог ее обнаружить, необходимо после создания проекта (как обычно, это — консольное приложение) подключить ссылку на библиотеку с помощью команды Project ► Add Reference (Добавить ссылку). Для поиска каталога, содержащего библиотеку, следует использовать кнопку Browse.
После подключения библиотеки можно пользоваться ее открытыми элементами таким же образом, как если бы они были описаны в том же модуле. Текст приложения приведен в листинге 12.2.
Результаты работы программы совпадают с полученными в листинге 8.3. Анализ каталога ...\bin\Debug показывает, что среда создала в нем копию библиотеки MonsterLib.dll, то есть поместила библиотеку в тот же каталог, что и исполняемый файл. Если скопировать эти два файла в другое место, программа не потеряет своей работоспособности — главное, чтобы оба файла находились в одном каталоге.
Допускается также, чтобы частные сборки находились в подкаталогах основного каталога приложения.
ПРИМЕЧАНИЕ ----------------------------------------------------------------------------------------------
Преимущество .NET состоит в том, что благодаря стандартным соглашениям можно использовать библиотеки независимо от языка, на котором они были написаны. Таким образом, можно было бы написать клиентское приложение, например, на языке VB.NET.
--------------------------------------------------------------------------------------------------------------------
Рефлексия — это получение информации о типах во время выполнения программы. Например, можно получить список всех классов и интерфейсов сборки, список элементов каждого из классов, список параметров каждого метода и т. д. Вся информация берется из метаданных сборки. Для использования рефлексии необходимы класс System.Type и типы пространства имен System.Reflection
В классе Туре описаны методы, которые позволяют получить информацию о типах. В пространстве имен System.Reflection описаны типы, поддерживающие Туре, а также классы, которые служат для организации позднего связывания и динамической загрузки сборок.
Наиболее важные свойства и методы класса Туре приведены в табл. 12.1.
Воспользоваться этими методами можно после создания экземпляра класса Туре. Поскольку это абстрактный класс, обычный способ создания объектов с помощью операции new неприменим, зато существуют три других способа:
1. В базовом классе object описан метод Get Ту ре, которым можно воспользоваться для любого объекта, поскольку он наследуется. Метод возвращает объект типа Туре, например:
Monster X = new Monster():
Type t = X.GetType();
2. В классе Type описан статический метод GetType с одним параметром строкового типа, на место которого требуется передать имя класса (типа), например:
Type t = Type.GetType( "Monster" ):
3. Операция typeof возвращает объект класса Туре для типа, заданного в качестве параметра, например:
Type t = typeof( Monster ):
При использовании второго и третьего способов создавать экземпляр исследуемого класса нет необходимости.
Как видно из табл. 12.1, многие методы класса Туре возвращают экземпляры стандартных классов (например, Memberlnfo). Эти классы описаны в пространстве имен System.Reflection. Наиболее важные из этих классов перечислены в табл. 12.2.
В листинге 12.3 приведены примеры использования рассмотренных методов и классов для получения информации о классах библиотеки из листинга 12.1
Можно продолжить исследования дальше, например, получить параметры и возвращаемое значение каждого метода. Думаю, что принцип вам уже ясен. Напомню, что вся эта информация берется из метаданных сборки.
Атрибуты — это дополнительные сведения об элементах программы (классах, методах, параметрах и т. д.). С помощью атрибутов можно добавлять информацию в метаданные сборки и затем извлекать ее во время выполнения программы. Атрибут является специальным видом класса и происходит от базового класса System.Attribute.
Атрибуты делятся на стандартные и пользовательские. В библиотеке .NET предусмотрено множество стандартных атрибутов, которые можно использовать в программах. Если всего разнообразия стандартных атрибутов не хватит, чтобы удовлетворить прихотливые требования программиста, он может описать собственные классы атрибутов, после чего применять их точно так же, как стандартные.
При использовании (спецификации) атрибутов они задаются в секции атрибутов, располагаемой непосредственно перед элементом, для описания которого они предназначены. Секция заключается в квадратные скобки и может содержать несколько атрибутов, перечисляемых через запятую. Порядок следования атрибутов произвольный.
Для каждого атрибута задаются имя, а также необязательные параметры и тип элемента сборки, к которому относится атрибут. Простейший пример атрибута:
[Serial izable]
class Monster
{
…
[NonSeriaiized]
string name;
int health, ammo;
} -
Атрибут [Serializable], означающий, что объекты этого класса можно сохранять во внешней памяти, относится ко всему классу Monster. При этом поле name помечено атрибутом [NonSerialized], что говорит о том, что это поле сохраняться не должно. Сохранение объектов рассматривалось в главе 10.
Обычно из контекста понятно, к какому элементу сборки относится атрибут, однако в некоторых случаях могут возникнуть неоднозначности. Для их устранения перед именем атрибута записывается тип элемента сборки — уточняющее ключевое слово, отделяемое от атрибута двоеточием. Ключевые слова и соответствующие элементы сборки, к которым могут относиться атрибуты, перечислены в табл. 12.3.
Пусть, например, перед методом описан гипотетический атрибут ABC:
[ABC]
public void Do() { ... }
По умолчанию он относится к методу. Чтобы указать, что атрибут относится не к методу, а к его возвращаемому значению, следует написать:
[return:ABC]
public void Do() { ... }
Атрибут может иметь параметры. Они записываются в круглых скобках через запятую после имени атрибута и бывают позиционными и именованными. Именованный параметр указывается в форме имя = значение, для позиционного просто задается значение. Например, для использованного в следующем фрагменте кода атрибута CLSCompliant задан позиционный параметр true. Атрибуты,
относящиеся к сборке, должны располагаться непосредственно после директив using, например:
using System;
[assembly:CLSCompliant(true)]
namespace ConsoleApplicationl
{ ...
Атрибут[CLSCompliant] определяет, удовлетворяет программный код соглашениям CLS (Common Language Specification) или нет.
Стандартные атрибуты, как и другие типы классов, имеют набор конструкторов, которые определяют, каким образом использовать (специфицировать) атрибут. Фактически, при использовании атрибута указывается наиболее подходящий конструктор, а величины, не указанные в конструкторе, задаются через именованные параметры в конце списка параметров.
Стандартный атрибут [STAThread], старательно удаленный из всех листингов в этой книге, относится к методу, перед которым он записан. Он имеет значение только для приложений, использующих модель СОМ, и задает модель потоков в рамках модели СОМ. Пример применения еще одного стандартного атрибута, [Conditional], приведен далее в разделе «Директивы препроцессора».
Атрибуты уровня сборки хранятся в файле Assemblylnfo.cs, автоматически создаваемом средой для любого проекта. Для явного задания номера версии сборки можно записать атрибут [AssemblyVersion], например:
[assembly: AssemblyVersion ('1.0.0.0")] ,
Создание пользовательских атрибутов выходит за рамки темы этого учебника. Интересующиеся могут обратиться к книге [27].
Пространство имен — это контейнер для типов, определяющий область их видимости. Пространства имен предотвращают конфликты имен и используются для двух взаимосвязанных целей:
□ логического группирования элементов программы, расположенных в различных физических файлах;
□ группирования имен, предоставляемых сборкой в пользование другим модулям.
Во всех программах, созданных ранее, использовалось пространство имен, создаваемое по умолчанию. Реальные программы чаще всего разрабатываются группой программистов, каждый из которых работает со своим набором физических файлов (единиц компиляции), хранящих элементы создаваемого приложения. Если в разных файлах описать пространства имен с одним и тем же именем, то при построении приложения, состоящего из этих файлов, будет скомпоновано единое пространство имен.
Пространства имен могут быть вложенными, например:
Вложенные пространства имен, как вы наверняка успели заметить, широко применяются в библиотеке .NET.
Существует три способа использования типа, определенного в каком-либо пространстве имен:
System.Runtime.Serialization.Formatters.Binary.BinaryFormatter bf = new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter( );
using System.Runtime.Serialization.Formatters.Binary;
….
BinaryFormatter bf = new BinaryFormatter( );
ВНИМАНИЕ -----------------------------------------------------------------------------------------------
Директива using должна располагаться вне или внутри пространства имен, но до любых описаний типов.
------------------------------------------------------------------------------------------------------------------
3. Использовать псевдоним типа. Это делается с помощью второй формы директивы using:
using BinF = System.Runtime.Serialization.Formatters.Binary.BinaryFormatter;
…
BinF bf = new BinF( )
Первый способ применяется при однократном использовании имени типа из «неглубоко» вложенных пространств имен, второй — в большинстве остальных случаев, что мы и делали во всех примерах, а третий можно рекомендовать при многократном использовании длинного имени типа.
В версию языка С# 2.0 введена возможность применять псевдоним прострет ей имен с помощью операции ::, например:
Использование псевдонима для пространства имен гарантирует, что последующие подключения других пространств имен к этой сборке не повлияют на существующие определения. Слева от операции :: можно указать идентификатор global. Он гарантирует, что поиск идентификатора, расположенного справа операции, будет выполняться только в глобальном пространстве имен. Цель пользования этого идентификатора та же: не допустить изменений существующих определений при разработке следующих версий программы, в которых в нее могут быть добавлены новые пространства имен, содержащие элементы с такими же именами.
Таким образом, сборки обеспечивают физическое группирование типов, а пространства имен — логическое. В мире сетевого программирования, когда программисту доступны десятки тысяч классов, пространства имен совершенно необходимы как для классификации и поиска, так и для предотвращения конфликтов имен типов.
Препроцессором в языке C++ называется предварительный этап компиляции, формирующий окончательный вариант текста программы. В языке С#, потомке C++, препроцессор практически отсутствует, но некоторые директивы сохранились. Назначение директив — исключать из процесса компиляции фрагменты кода при выполнении определенных условий, выводить сообщения об ошибках и предупреждения, а также структурировать код программы.
Каждая директива располагается на отдельной строке и не заканчивается точ с запятой, в отличие от операторов языка. В одной строке с директивой мо Располагаться только комментарий вида //. Перечень и краткое описание директив приведены в табл. 12.4.
Рассмотрим более подробно применение директив условной компиляции. Они используются для того, чтобы исключить компиляцию отдельных частей программы. Это бывает полезно при отладке или, например, при поддержке нескольких версий программы для различных платформ.
Количество директив #el i f произвольно. Исключаемые блоки кода могут содержать как описания, так и исполняемые операторы. Константное выражение может содержать одну или несколько символьных констант, объединенных знаками операций = =, ! =, !, && и ||. Также допускаются круглые скобки. Константа считается равной true, если она была ранее определена с помощью директивы #define.
Пример применения директив приведен в листинге 12.4.
В зависимости от того, определение какой символьной константы раскомментировать, в компиляции будет участвовать один из трех методов F.
Директива #define применяется не только в сочетании с директивами условной компиляции. Можно применять ее вместе со стандартным атрибутом Conditional для условного управления выполнением методов. Метод будет выполняться, если константа определена. Пример приведен в листинге 12.5. Обратите внимание на то, что для применения атрибута необходимо подключить пространство имен System.Diagnostics.
В методе Main записаны вызовы обоих методов, однако в данном случае будет выполнен только метод В, поскольку символьная константа VAR1 не определена.
Рекомендации по программированию
В этой главе приведено краткое введение в средства, использующиеся при профессиональной разработке программ: библиотеки, рефлексия типов, атрибуты, пространства имен и директивы препроцессора. Для реального использования этих возможностей в программах необходимо изучать документацию и дополнительную литературу, например [20], [21], [26], [27], [30].