□ Атрибут AssemblyVersion при применении к сборке задает версию сборки.
□ Атрибут Flags при применении к перечислимому типу превращает перечислимый тип в набор битовых флагов.
Рассмотрим код с множеством примененных к нему атрибутов. В C# имена настраиваемых атрибутов помещаются в квадратные скобки непосредственно перед именем класса, объекта и т. п. Fie пытайтесь понять, что именно делает код; я всего лишь хочу показать, как выглядят атрибуты:
using System;
using System.Runtime.InteropServices;
[Structl_ayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] internal sealed class OSVERSIONINFO { public OSVERSIONINFO() {
OSVersionlnfoSize = (UInt32) Marshal.SizeOf(this);
}
public UInt32 OSVersionlnfoSize = 0; public UInt32 MajorVersion = 0; public UInt32 MinorVersion = 0; public UInt32 BuildNumber = 0; public UInt32 Platformld = 0;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] public String CSDVersion = null;
>
internal sealed class MyClass {
[DllImport("Kernel32", CharSet = CharSet.Auto, SetLastError = true)] public static extern Boolean GetVersionEx([In, Out] OSVERSIONINFO ver);
>
В данном случае атрибут Struct Layout применяется к классу OSVERSIONINFO, атрибут MarshalAs — к полю CSDVersion, атрибут Dlllmport — к методу GetVersionEx, а атрибуты In и Out — к параметру ver метода GetVersionEx. В каждом языке определяется свой синтаксис применения настраиваемых атрибутов. Например, в Visual Basic .NET вместо квадратных скобок используются угловые (< >).
CLR позволяет применять атрибуты ко всему, что может быть представлено метаданными. Чаще всего они применяются к записям в следующих таблицах определений: TypeDef (классы, структуры, перечисления, интерфейсы и делегаты), MethodDef (конструкторы), ParamDef, FieldDef, PropertyDef, EventDef, AssemblyDef и ModuleDef. В частности, C# позволяет применять настраиваемые атрибуты только к исходному коду, определяющему такие элементы, как сборки, модули, типы (класс, структура, перечисление, интерфейс, делегат), поля, методы (в том числе конструкторы), параметры методов, возвращаемые значения методов, свойства, события, параметры обобщенного типа.
Вы можете задать префикс, указывающий, к чему будет применен атрибут. Возможные варианты префиксов представлены в показанном далее фрагменте кода. Впрочем, как понятно из предыдущего примера, компилятор часто способен определить назначение атрибута даже при отсутствии префикса. Обязательные префиксы выделены полужирным шрифтом:
using System;
[assembly: SomeAttr] // Применяется к сборке [module: SomeAttr] // Применяется к модулю
[type: SomeAttr] // Применяется к типу
internal sealed class SomeType<[typevar: SomeAttr] T> { // Применяется
// к переменной обобщенного типа
[field: SomeAttr] // Применяется к полю
public Int32 SomeField = 0;
[return: SomeAttr] // Применяется к возвращаемому значению
[method: SomeAttr] // Применяется к методу
public Int32 SomeMethod(
[param: SomeAttr] // Применяется к параметру Int32 SomeParam) { return SomeParam; }
[property: SomeAttr] // Применяется к свойству public String SomeProp {
[method: SomeAttr] // Применяется к механизму доступа get get { return null; }
}
[event: SomeAttr] // Применяется к событиям
[field: SomeAttr] // Применяется к полям, созданным компилятором
[method: SomeAttr] // Применяется к созданным
// компилятором методам add и remove public event EventHandler SomeEvent;
}
Теперь, когда вы знаете, как применять настраиваемые атрибуты, давайте разберемся, что они собой представляют. Настраиваемый атрибут — это всего лишь
экземпляр типа. Для соответствия общеязыковой спецификации (CLS) он должен прямо или косвенно наследовать от абстрактного класса System.Attribute. В C# допустимы только CLS-совместимые атрибуты. В документации на .NET Framework SDK можно обнаружить определения следующих классов из предыдущего примера: StructLayoutAttribute, MarshalAsAttribute, DllImportAttribute, InAttribute и OutAttribute. Все они находятся в пространстве имен System. Runtime. InteropServices, при этом классы атрибутов могут определяться в любом пространстве имен. Можно заметить, что все перечисленные классы являются производными от класса System. Attribute, как и положено CLS-совместимым атрибутам.
ПРИМЕЧАНИЕ
При определении атрибута компилятор позволяет опускать суффикс Attribute, что упрощает ввод кода и делает его более читабельным. Я активно использую эту возможность в приводимых в книге примерах — например, пишу [Dlllmport(...)] вместо [DlllmportAttribute(...)].
Как уже упоминалось, атрибуты являются экземплярами класса. И этот класс должен иметь открытый конструктор для создания экземпляров. А значит, синтаксис применения атрибутов аналогичен вызову конструктора. Кроме того, используемый язык может поддерживать специальный синтаксис определения открытых полей или свойств класса атрибутов. Рассмотрим это на примере. Вернемся к приложению, в котором атрибут Dlllmport применяется к методу GetVersionEx:
[DllImport("Kernel32", CharSet = CharSet.Auto, SetLastError = true)]
Выглядит довольно странно, но вряд ли вы будете когда-нибудь использовать подобный синтаксис для вызова конструктора. Согласно описанию класса DllImportAttribute в документации, его конструктор требует единственного параметра типа String. В рассматриваемом примере в качестве параметра передается строка "Кегпе132". Параметры конструктора называются позиционными (positional parameters); при применении атрибута следует обязательно их указывать.
А что с еще двумя «параметрами»? Показанный особый синтаксис позволяет задавать любые открытые поля или свойства объекта DllImportAttribute после его создания. В рассматриваемом примере при создании этого объекта его конструктору передается строка "Кегпе132", а открытым экземплярным полям CharSet и SetLastError присваиваются значения CharSet.Auto и true соответственно. «Параметры», задающие поля или свойства, называются именованными (named parameters); они являются необязательными. Чуть позже мы рассмотрим, как инициировать конструирование экземпляра класса DllImportAttribute.
Следует заметить, что к одному элементу можно применить несколько атрибутов. Скажем, в приведенном в начале главы фрагменте кода к параметру ver метода GetVersionEx применяются атрибуты In и Out. Учтите, что порядок следования атрибутов в такой ситуации не имеет значения. В C# отдельные атрибуты могут заключаться в квадратные скобки; также возможно перечисление наборов атрибутов в этих скобках через запятую. Если конструктор класса атрибута не имеет параметров, круглые скобки можно опустить. Ну и, как уже упоминалось, суффикс Attribute также является необязательным. Показанные далее строки приводят к одному и тому же результату и демонстрируют все возможные способы применения набора атрибутов:
[Serializable][Flags]
[Serializable, Flags]
[FlagsAttribute, SerializableAttribute]
[ FlagsAttribute() ] [Serial izableQ ]
Определение класса атрибутов
Вы уже знаете, что любой атрибут представляет собой экземпляр класса, производного от System.Attribute, и умеете применять атрибуты. Пришло время рассмотреть процесс их создания. Представьте, что вы работаете в Microsoft и получили задание реализовать поддержку битовых флагов в перечислимых типах. Для начала нужно определить класс FlagsAttribute:
namespace System {
public class FlagsAttribute : System.Attribute { public FlagsAttributeQ {
}
}
}
Обратите внимание, что класс FlagsAttribute наследует от класса Attribute; именно это делает его CLS-совместимым. Вдобавок в имени класса присутствует суффикс Attribute. Это соответствует стандарту именования, хотя и не является обязательным. Наконец, все неабстрактные атрибуты должны содержать хотя бы один открытый конструктор. Простейший конструктор FlagsAttribute не имеет параметров и не выполняет никаких действий.
ВНИМАНИЕ
Атрибут следует рассматривать как логический контейнер состояния. Иначе говоря, хотя атрибут и является классом, этот класс должен быть крайне простым. Он должен содержать всего один открытый конструктор, принимающий обязательную (или позиционную) информацию о состоянии атрибута. Также класс может содержать открытые поля/свойства, принимающие дополнительную (или именованную) информацию о состоянии атрибута. В классе не должно быть открытых методов, событий или других членов.
В общем случае я не одобряю использование открытых полей. Атрибутов это тоже касается. Лучше воспользоваться свойствами, так как они обеспечивают большую гибкость в случаях, когда требуется внести изменения в реализацию класса атрибутов.
Получается, что экземпляры класса FlagsAttnibute можно применять к чему угодно, хотя реально этот атрибут следует применять только к перечислимым типам. Нет смысла применять его к свойству или методу. Чтобы указать компилятору область действия атрибута, применим к классу атрибута экземпляр класса System. AttnibuteUsageAttnibute:
namespace System {
[Attributellsage(AttributeTargets. Enum, Inherited = false)] public class FlagsAttribute : System.Attribute { public FlagsAttributeQ {
}
}
В этой новой версии экземпляр AttributeUsageAttribute применяется к атрибуту. В конце концов, атрибуты — это всего лишь классы, а значит, к ним, в свою очередь, можно применять другие атрибуты. Атрибут AttributeUsage является простым классом, указывающим компилятору область действия настраиваемого атрибута. Все компиляторы имеют встроенную поддержку этого атрибута и при попытке применить его к недопустимому элементу выдают сообщение об ошибке. В рассматриваемом примере атрибут AttributeUsage указывает, что экземпляры атрибута Flags работают только с перечислимыми типами.
Так как все атрибуты являются типами, понять, как устроен класс AttributeUsageAttribute, несложно. Вот исходный код этого класса в FCL:
[Serializable]
[AttributeUsage(AttributeTargets.Class, Inherited=true)] public sealed class AttributeUsageAttribute : Attribute {
internal static AttributeUsageAttribute Default = new AttributeUsageAttribute(AttributeTargets.All);
internal Boolean m_allowMultiple = false;
internal AttributeTargets m_attributeTarget = AttributeTargets.All;
internal Boolean m_inherited = true;
// Единственный открытый конструктор
public AttributeUsageAttribute(AttributeTargets validOn) { m_attributeTarget = validOn;
}
internal AttributeUsageAttribute(AttributeTargets validOn,
Boolean allowMultiple, Boolean inherited) { m_attributeTarget = validOn; mallowMultiple = allowMultiple; minherited = inherited;
}
public Boolean AllowMultiple { get { return m_allowMultiple; } set { m_allowMultiple = value; }
}
public Boolean Inherited { get { return minherited; } set { m_inherited = value; }
>
public AttributeTargets ValidOn { get { return mattributeTarget; }
>
Как видите, класс AttributeUsageAttribute имеет открытый конструктор, который позволяет передавать битовые флаги, обозначающие область применения атрибута. Перечислимый тип System .AttributeTargets определяется в FCL так:
[Flags, Serializable] public enum AttributeTargets {
Assembly = 0x0001,
Module = 0x0002,
Class = 0x0004,
Struct = 0x0008,
Enum = 0x0010,
Constructor = 0x0020,
Method = 0x0040,
Property = 0x0080,
Field = 0x0100,
Event = 0x0200,
Interface = 0x0400,
Parameter = 0x0800,
Delegate = 0x1000,
ReturnValue = 0x2000,
GenericParameter = 0x4000,
All = Assembly | Module | Class | Struct | Enum |
Constructor | Method | Property | Field | Event |
Interface | Parameter | Delegate | ReturnValue |
GenericParameter
}
У класса AttributeUsageAttribute есть два дополнительных открытых свойства, которым при применении атрибута к классу могут быть присвоены значения AllowMultiple и Inherited.
Большинство атрибутов не имеет смысла применять к одному элементу более одного раза. Например, вам ничего не даст последовательное применение атрибута Flags или Serializable:
[Flags][Flags] internal enum Color {
Red
}
Более того, при попытке компиляции такого кода появится сообщение об ошибке (ошибка CS0579: дублирование атрибута Flags):
error CS0579: Duplicate 'Flags' attribute
Однако есть и атрибуты, многократное применение которых оправдано — в FCL это класс атрибутов ConditionalAttribute. Для этого параметру AllowMultiple
должно быть присвоено значение true. В противном случае многократное применение атрибута невозможно.
Свойство Inherited класса AttributeUsageAttribute указывает, будет ли атрибут, применяемый к базовому классу, применяться также к производным классам и переопределенным методам. Суть наследования атрибута демонстрирует следующий код:
[Attributellsage(AttributeTargets.Class | AttributeTargets.Method,
Inherited=true)]
internal class TastyAttribute : Attribute {
}
[Tasty][Serializable] internal class BaseType {
[Tasty] protected virtual void DoSomething() { }
}
internal class DerivedType : BaseType { protected override void DoSomething() { }
}
В этом коде класс DerivedType и его метод DoSomething снабжены атрибутом Tasty, так как класс TastyAttribute помечен как наследуемый. При этом класс DerivedType несериализуемый, потому что класс SerializableAttribute в FCL
помечен как ненаследуемый атрибут.
Следует помнить, что в .NET Framework наследование атрибутов допустимо только для классов, методов, свойств, событий, полей, возвращаемых значений и параметров. Не забывайте об этом, присваивая параметру Inherited значение true. Кстати, при наличии наследуемых атрибутов дополнительные метаданные в управляемый модуль для производных типов не добавляются. Более подробно мы поговорим об этом чуть позже.
ПРИМЕЧАНИЕ
Если при определении собственного класса атрибутов вы забудете применить атрибут Attributellsage, компилятор и CLR будут рассматривать полученный результат как применимый к любым элементам, но только один раз. Кроме того, он будет наследуемым. Именно такие значения по умолчанию имеют поля класса AttributeUsageAttribute.
Конструктор атрибута и типы данных полей и свойств
Определяя класс настраиваемых атрибутов, можно указать конструктор с параметрами, которые должен задавать разработчик, использующий экземпляр атрибута.
Кроме того, вы можете определить нестатические открытые поля и свойства своего типа, которые разработчик может задавать по желанию.
Определяя конструктор экземпляров класса атрибутов, а также поля и свойства, следует ограничиться небольшим набором типов данных. Допустимы типы: Boolean, Char, Byte, SByte, Intl6, UIntl6, Int32, UInt32, Int64, UInt64, Single, Double, String, Type, Object и перечислимые типы. Можно использовать также одномерные массивы этих типов с нулевой нижней границей, но это не рекомендуется, так как класс настраиваемых атрибутов, конструктор которого умеет работать с массивами, не относится к CLS-совместимым.
Применяя атрибут, следует указывать определенное при компиляции постоянное выражение, совпадающее с типом, заданным классом атрибута. Каждый раз, когда класс атрибута определяет параметр, поле или свойство типа Туре, следует использовать оператор typeof языка С#, как показано в следующем фрагменте кода. А для параметров, полей и свойств типа Object можно передавать значения типа Int32, String и другие постоянные выражения (в том числе null). Если постоянное выражение принадлежит к значимому типу, этот тип будет упакован при создании экземпляра атрибута.
Пример применения атрибута:
using System;
internal enum Color { Red }
[AttributeUsage(AttributeTargets.All)] internal sealed class SomeAttribute : Attribute {
public SomeAttribute(String name, Object o, Type[] types) {
// 'name' ссылается на String
// 'o' ссылается на один из легальных типов (упаковка при необходимости)
// 'types' ссылается на одномерный массив Types // с нулевой нижней границей
}
}
[Some("leff", Color.Red, new Type[] { typeof(Math), typeof(Console) })] internal sealed class SomeType {
}
Обнаружив настраиваемый атрибут, компилятор создает экземпляр класса этого атрибута, передавая его конструктору все указанные параметры. Затем он присваивает значения открытым полям и свойствам, используя для этого усовершенствованный синтаксис конструктора. Инициализировав объект настраиваемого атрибута, компилятор сериализует его и сохраняет в таблице метаданных.
ВНИМАНИЕ
Настраиваемый атрибут лучше всего представлять себе как экземпляр класса, сериализованный в байтовый поток, находящийся в метаданных. В период выполнения байты из метаданных десериализуются для конструирования экземпляра класса.
На самом деле компилятор генерирует информацию, необходимую для создания экземпляра класса атрибутов, и размещает ее в метаданных. Каждый параметр конструктора записывается с однобайтным идентификатором, за которым следует его значение. Завершив «сериализацию» параметров, компилятор генерирует значения для каждого указанного поля и свойства, записывая его имя, за которым следует однобайтный идентификатор типа и значение. Для массивов сначала указывается количество элементов.
Выявление настраиваемых атрибутов
Само по себе определение атрибутов бесполезно. Вы можете определить любой класс атрибута и применить его в произвольном месте, но это приведет только к появлению в вашей сборке дополнительных метаданных, никак не влияя на работу приложения.
Как показано в главе 15, применение к перечислимому типу System. Enum атрибута Flags меняет поведение его методов ToString и Format. Причиной этому является происходящая во время выполнения проверка, не связан ли атрибут Flags с перечислимым типом, с которым работают данные методы. Код может анализироваться на наличие атрибутов при помощи технологии, называемой отражением (reflection). Подробно она рассматривается в главе 23, здесь же я только продемонстрирую ее применение.
Если бы вы были разработчиком из Microsoft, которому поручено реализовать метод Format типа Enum, вы бы сделали это примерно так:
public override String ToStringQ {
// Применяется ли к перечислимому типу экземпляр типа FlagsAttribute? if (this.GetType().IsDefined(typeof(FlagsAttribute), false)) {
// Да; выполняем код, интерпретирующий значение как // перечислимый тип с битовыми флагами
} else {
// Нет; выполняем код, интерпретирующий значение как // обычный перечислимый тип
}
}
Этот код обращается к методу IsDefined типа Туре, заставляя систему посмотреть метаданные этого перечислимого типа и определить, связан ли с ним экземпляр класса FlagsAttribute. Если метод IsDefined возвращает значение true, значит, экземпляр FlagsAttribute связан с перечислимым типом, и метод Format будет считать, что переданное значение содержит набор битовых флагов. В противном случае переданное значение будет восприниматься как обычный перечислимый тип.
То есть после определения собственных классов атрибутов нужно также написать код, проверяющий, существует ли экземпляр класса атрибута (для указанных элементов), и в зависимости от результата меняющий порядок выполнения программы. Только в этом случае настраиваемый атрибут принесет пользу!
Проверить наличие атрибута в FCL можно разными способами. Для объектов класса System .Туре можно использовать метод IsDefined, как показано ранее. Но иногда требуется проверить наличие атрибута не для типа, а для сборки, модуля или метода. Остановимся на методах класса System.Reflection.CustomAttribute- Extensions. Именно он является базовым для CLS-совместимых атрибутов. В этом классе для получения атрибутов имеются три статических метода: IsDefined, GetCustomAttributes и GetCustomAttribute. Каждый из них имеет несколько перегруженных версий. К примеру, одна версия каждого из методов работает с членами типа (классами, структурами, перечислениями, интерфейсами, делегатами, конструкторами, методами, свойствами, полями, событиями и возвращаемыми типами), параметрами и сборками. Также существуют версии, позволяющие просматривать иерархию наследования и включать в результат наследуемые атрибуты. Краткое описание методов дано в табл. 18.1.
Таблица 18.1. Методы класса System.Reflection.CustomAttributeExtensions, определяющие наличие в метаданных CLS-совместимых настраиваемых атрибутов
|
|
Если нужно установить только сам факт применения атрибута, используйте метод IsDefined как самый быстрый из перечисленных. Однако при применении атрибута, как известно, можно задавать параметры его конструктору и при необходимости определять свойства и поля, а этого метод IsDefined делать не умеет.
Для создания объектов атрибутов используйте метод GetCustomAttributes или GetCustomAtt nibute. При каждом вызове этих методов создаются экземпляры указанных классов атрибутов, и на основе указанных в исходном коде значений задаются поля и свойства каждого экземпляра. Эти методы возвращают ссылки на сконструированные экземпляры классов атрибутов.
Эти методы просматривают данные управляемого модуля и сравнивают строки в поиске указанного класса настраиваемого атрибута. Эти операции требуют времени, поэтому если вас волнует быстродействие, подумайте о кэшировании результатов работы методов. В этом случае вам не придется вызывать их раз за разом, запрашивая одну и ту же информацию.
В пространстве имен System. Reflection находятся классы, позволяющие анализировать содержимое метаданных модуля: Assembly, Module, Parameterlnfo, Membenlnfo, Type, Methodlnfo, Constnuctonlnfo, Fieldlnfo, Eventlnfo, Propertylnfo и соответствующие им классы *Builden. Все эти классы содержат методы IsDefined и GetCustomAttributes.
Версия метода GetCustomAttributes, определенная в классах, связанных с отражением, возвращает массив экземпляров Object (Object[]) вместо массива экземпляров типа Attribute (Attribute[ ]). Дело в том, что классы, связанные с отражением, могут возвращать объекты из классов атрибута, не соответствующих спецификации CLS. К счастью, такие атрибуты встречаются крайне редко. За все время моей работы с .NET Framework я не сталкивался с ними ни разу.
ПРИМЕЧАНИЕ
Имейте в виду, что методы отражения, поддерживающие логический параметр inherit, реализуют только классы Attribute, Туре и Methodlnfo. Все прочие методы отражения этот параметр игнорируют и иерархию наследования не проверяют. Для проверки наличия унаследованного атрибута в событиях, свойствах, полях, конструкторах или параметрах используйте один из методов класса Attribute.
Есть еще один аспект, о котором следует помнить. После передачи класса методам IsDefined, GetCustomAttribute или GetCustomAttributes они начинают искать этот класс атрибута или производные от него. Для поиска конкретного класса атрибута требуется дополнительная проверка возвращенного значения, которая гарантирует, что возвращен именно тот класс, который вам нужен. Чтобы избежать недоразумений и дополнительных проверок, можно определить класс с модификатором sealed.
Вот пример рассмотрения методов внутри типа и отображения применяемых к каждому их методов атрибутов. Это демонстрационный код; обычно никто не применяет указанные настраиваемые атрибуты подобным образом:
using System;
using System.Diagnostics;
using System.Reflection;
[assembly: CLSCompliant(true)]
[Serializable]
[DefaultMemberAttribute("Main")]
[DebuggerDisplayAttribute("Richter“, Name = "leff",
Target = typeof(Program))]
public sealed class Program {
[Conditional("Debug")]
[Conditional("Release")] public void DoSomething() { }
public ProgramQ {
}
[CLSCompliant(true)]
[STAThread]
public static void Main() {
// Вывод набора атрибутов, примененных к типу ShowAttгibutes(typeof(Program));
// Получение и задание методов, связанных с типом
var members =
from m in typeof(Program).GetTypelnfoQ.DeclaredMembers.OfType<MethodBase>() where m.IsPublic select m;
foreach (Memberlnfo member in members) {
// Вывод набора атрибутов, примененных к члену ShowAttributes(member);
}
}
private static void ShowAttributes(MemberInfo attributeTarget) { var attributes = attributeTarget.GetCustomAttributes<Attribute>();
Console.WriteLine("Attributes applied to {0}: {1}",
attributeTarget.Name, (attributes.Count() == 0 ? "None" : String.Empty));
foreach (Attribute attribute in attributes) {
// Вывод типа всех примененных атрибутов
Console.WriteLine(" {0}", attribute.GetType().ToString());
if (attribute is DefaultMemberAttribute)
Console.WriteLine(" MemberName={0}",
((DefaultMemberAttribute) attribute).MemberName);
if (attribute is ConditionalAttribute)
Console.Write Line(" ConditionString={0}",
((ConditionalAttribute) attribute).Conditionstring);
if (attribute is CLSCompliantAttribute)
Console.Write Line(" IsCompliant={0}",
((CLSCompliantAttribute) attribute).IsCompliant);
DebuggerDisplayAttribute dda = attribute as DebuggerDisplayAttribute; if (dda != null) {
Console.WriteLine(" Value={0}, Name={l}, Target={2}", dda.Value, dda.Name, dda.Target);
}
}
Console.WriteLine();
}
}
Скомпоновав и запустив это приложение, мы получим следующий результат:
Attributes applied to Program:
System.SerializableAttribute System.Diagnostics.DebuggerDisplayAttribute Value=Richter, Name=Teff, Target=Program System.Reflection.DefaultMemberAttribute MemberName=Main
Attributes applied to DoSomething:
System.Diagnostics.ConditionalAttribute ConditionString=Release System.Diagnostics.ConditionalAttribute ConditionString=Debug
Attributes applied to Main:
System.CLSCompliantAttribute IsCompliant=True System.STAThreadAttribute
Attributes applied to .ctor: None
Сравнение экземпляров атрибута
Теперь, когда вы умеете находить экземпляры атрибутов в коде, имеет смысл рассмотреть процедуру проверки значений, хранящихся в их полях. Можно, к примеру, написать код, явным образом проверяющий значение каждого поля класса атрибута. Однако класс System.Attribute переопределяет метод Equals класса Object, заставляя его сравнивать типы объектов. Если они не совпадают, метод возвращает значение false. В случае же совпадения метод Equals использует отражения для сравнения полей двух атрибутов (вызывая метод Equals для каждого поля). Если все поля совпадают, возвращается значение true. Можно переопределить метод Equals в вашем собственном классе атрибутов, убрав из него отражения и повысив тем самым производительность.
Класс System.Attribute содержит также виртуальный метод Match, который вы можете переопределить для получения более богатой семантики. По умолчанию данный метод просто вызывает метод Equals и возвращает полученный результат. Следующий код демонстрирует переопределение методов Equals и Match (значение true возвращается, если один атрибут представляет собой подмножество другого) и применение второго из них:
using System;
[Flags]
internal enum Accounts {
Savings = 0x0001,
Checking = 0x0002,
Brokerage = 0x0004
}
[AttributeUsage(AttributeTargets.Class)] internal sealed class AccountsAttribute : Attribute { private Accounts maccounts;
public AccountsAttribute(Accounts accounts) { maccounts = accounts;
}
public override Boolean Match(Object obj) {
// Если в базовом классе реализован метод Match и это не // класс Attribute, раскомментируйте следующую строку // if (!base.Match(obj)) return false;
// Так как 'this' не равен null, если obj равен null,
// объекты не совпадают
// ПРИМЕЧАНИЕ. Эту строку можно удалить, если вы считаете,
// что базовый тип корректно реализует метод Match if (obj == null) return false;
// Объекты разных типов не могут быть равны // ПРИМЕЧАНИЕ. Эту строку можно удалить, если вы считаете,
// что базовый тип корректно реализует метод Match if (this.GetType() != obj.GetTypeQ) return false;
// Приведение obj к нашему типу для доступа к полям // ПРИМЕЧАНИЕ. Это приведение всегда работает,
// так как объекты принадлежат к одному типу AccountsAttribute other = (AccountsAttribute) obj;
// Сравнение полей
II Проверка, является ли accounts 'this' подмножеством // accounts объекта others
if ((other.maccounts & m_accounts) != maccounts)
return false;
return true; // Объекты совпадают
}
public override Boolean Equals(ObJect obj) {
// Если в базовом классе реализован метод Equals и это // не класс Object, раскомментируйте следующую строку // If (!base.Equals(obJ)) return false;
// Так как 'this' не равен null, при obj равном null // объекты не совпадают
// ПРИМЕЧАНИЕ. Эту строку можно удалить, если вы считаете,
// что базовый тип корректно реализует метод Equals If (obj == null) return false;
// Объекты разных типов не могут совпасть
// ПРИМЕЧАНИЕ. Эту строку можно удалить, если вы считаете,
// что базовый тип корректно реализует метод Equals If (this.GetTypeQ != obj.GetTypeQ) return false;
// Приведение obj к нашему типу для получения доступа к полям // ПРИМЕЧАНИЕ. Это приведение работает всегда,
// так как объекты принадлежат к одному типу AccountsAttribute other = (AccountsAttribute) obj;
// Сравнение значений полей 'this' и other If (other.maccounts != m_accounts) return false;
return true; // Объекты совпадают
}
// Переопределяем GetHashCode, так как Equals уже переопределен public override Int32 GetHashCode() { return (Int32) maccounts;
}
[Accounts(Accounts.Savings)]
Internal sealed class ChildAccount { }
[Accounts(Accounts.Savings | Accounts.Checking | Accounts.Brokerage)] Internal sealed class AdultAccount { }
public sealed class Program { public static void Main() {
CanWriteCheck(new ChildAccount());
CanWriteCheck(new AdultAccount());
// Просто для демонстрации корректности работы метода для // типа, к которому не был применен атрибут AccountsAttribute
Canl/driteCheck(new Program());
}
private static void Canl/\lriteCheck(0bject obj) {
// Создание и инициализация экземпляра типа атрибута Attribute checking = new AccountsAttribute(Accounts.Checking);
// Создание экземпляра атрибута, примененного к типу Attribute validAccounts =
obj.GetType().GetCustomAttribute<AccountsAttribute>(false);
// Если атрибут применен к типу и указывает на счет "Checking",
// значит, тип может выписывать чеки
if ((validAccounts != null) && checking.Match(validAccounts)) { Console.WriteLine("{0} types can write checks.", obj.GetTypeQ);
} else {
Console.WriteLine("{0} types can NOT write checks.", obj.GetTypeQ);
}
}
Построение и запуск этого приложения приводит к следующему результату:
ChildAccount types can NOT write checks.
AdultAccount types can write checks.
Program types can NOT write checks.
Выявление настраиваемых атрибутов без создания объектов, производных от Attribute
В этом разделе мы поговорим об альтернативном способе выявления настраиваемых атрибутов, примененных к метаданным. В ситуациях, требующих повышенной безопасности, этот способ гарантированно предотвращает выполнение кода класса, производного от Attribute. Вообще говоря, при вызове методов GetCustomAttribute(s) типа Attribute вызывается конструктор класса атрибута и методы, задающие значения свойств. А первое обращение к типу заставляет CLR вызвать конструктор этого типа (если он, конечно, существует). Конструктор, метод доступа set и методы конструктора типа могут содержать код, выполняющийся при каждом поиске атрибута. Возможность выполнения в домене приложения неизвестного кода создает потенциальную угрозу безопасности.
Для обнаружения атрибутов без выполнения кода класса атрибута применяется класс System. Ref lection .CustomAttributeData. В нем определен единственный статический метод GetCustomAttributes, позволяющий получить информацию о примененных атрибутах. Этот метод имеет четыре перегруженные версии: одна
принимает параметр типа Assembly, другая — Module, третья — Parameterlnf о, последняя — Memberlnfo. Класс определен в пространстве имен System. Reflection, о котором будет рассказано в главе 23. Обычно класс CustomAttributeData используется для анализа метаданных сборки, загружаемой статическим методом Ref lectionOnlyLoad класса Assembly (он также рассматривается в главе 23). Пока же достаточно сказать, что этот метод загружает сборку таким образом, что CLR не может выполнять какой-либо код, в том числе конструкторы типов.
Метод GetCustomAttributes класса CustomAttributeData работает как метод-фабрика: он возвращает набор объектов CustomAttributeData в объекте типа IList<CustomAttributeData>. Каждому элементу этой коллекции соответствует один настраиваемый атрибут. Для каждого объекта класса CustomAttributeData можно запросить предназначенные только для чтения свойства, определив в результате, каким способом мог бы быть сконструирован и инициализирован объект. Например, свойство Constructor указывает, какой именно конструктор мог бы быть вызван. Свойство ConstructorArguments возвращает аргументы, которые могли бы быть переданы конструктору в качестве экземпляра IListcCustomAttributeTyped Argument». Свойство NamedArguments возвращает поля и свойства, которые могли бы быть заданы как экземпляр IList<CustomAttributeNamedArgument>. Условное наклонение во всех этих предложениях обусловлено тем, что ни конструктор, ни метод доступа set на самом деле не вызываются. Для безопасности запрещено выполнение любых методов класса атрибута.
Ниже приведена измененная версия предыдущего кода, в которой для безопасного получения атрибутов используется класс CustomAttributeData:
using System;
using System.Diagnostics;
using System.Reflection;
using System.Collections.Generic;
[assembly: CLSCompliant(true)]
[Serializable]
[DefaultMemberAttribute("Main")]
[DebuggerDisplayAttribute("Richter", Name="3eff", Target=typeof(Program))] public sealed class Program {
[Conditional("Debug")]
[Conditional("Release")] public void DoSomething() { }
public Program() {
}
[CLSCompliant(true)]
[STAThread]
public static void Main() {
// Вывод атрибутов, примененных к данному типу ShowAttributes(typeof(Program));
// Получение набора связанных с типом методов MemberInfo[] members = typeof(Program).FindMembers(
MemberTypes.Constructor | MemberTypes.Method,
BindingFlags.DeclaredOnly | BindingFlags.Instance |
BindingFlags.Public | BindingFlags.Static,
Type.FilterNamej "*");
foreach (Memberlnfo member in members) {
// Вывод атрибутов, примененных к данному члену ShowAttributes(member);
У
}
private static void ShowAttributes(MemberInfo attributeTarget) { IList<CustomAttributeData> attributes =
CustomAttributeData.GetCustomAttributes(attributeTarget);
Console.WriteLine("Attributes applied to {0}: {1}", attributeTarget.Name, (
attributes.Count == 0 ? "None" : String.Empty));
foreach (CustomAttributeData attribute in attributes) {
// Вывод типа каждого примененного атрибута Type t = attribute.Constructor.DeclaringType;
Console.WriteLine(" {0}", t.ToString())j
Console.Writeline(" Constructor called={0}“, attribute.Constructor);
IList<CustomAttributeTypedArgument> posArgs = attribute.ConstructorArguments;
Console.WriteLine(" Positional arguments passed to constructor:" + ((posArgs.Count == 0) ? " None" : String.Empty)); foreach (CustomAttributeTypedArgument pa in posArgs) {
Console.WriteLine(" Type={0}j Value={l}“, pa.ArgumentTypej pa.Value);
}
IList<CustomAttributeNamedArgument> namedArgs = attribute.NamedArguments;
Console.WriteLine(" Named arguments set after construction:" + ((namedArgs.Count == 0) ? " None" : String.Empty)); foreach(CustomAttributeNamedArgument na in namedArgs) {
Console.WriteLine(" Name={0}j Type={l}j Value={2}"j na.Memberlnfo.Name, na.TypedValue.ArgumentType, na.TypedValue.Value);
}
Console.WriteLine();
}
Console.WriteLine();
>
Компоновка и запуск этого приложения приведут к следующему результату:
Attributes applied to Program:
System.SerializableAttribute Constructor called=Void .ctor()
Positional arguments passed to constructor: None Named arguments set after construction: None
System.Diagnostics.DebuggerDisplayAttribute Constructor called=Void .ctor(System.String)
Positional arguments passed to constructor:
Type=System.String, Value=Richter Named arguments set after construction:
Name=Name, Type=System.String, Value=Teff Name=Target, Type=System.Type, Value=Program
System.Reflection.DefaultMemberAttribute
Constructor called=Void .ctor(System.String)
Positional arguments passed to constructor:
Type=System.String, Value=Main Named arguments set after construction: None
Attributes applied to DoSomething:
System.Diagnostics.ConditionalAttribute
Constructor called=Void .ctor(System.String)
Positional arguments passed to constructor:
Type=System.String, Value=Release Named arguments set after construction: None
System.Diagnostics.ConditionalAttribute
Constructor called=Void .ctor(System.String)
Positional arguments passed to constructor:
Type=System.String, Value=Debug Named arguments set after construction: None
Attributes applied to Main:
System.CLSCompliantAttribute
Constructor called=Void .ctor(Boolean)
Positional arguments passed to constructor:
Type=System.Boolean, Value=True Named arguments set after construction: None
System.STAThreadAttribute
Constructor called=Void .ctor()
Positional arguments passed to constructor: None Named arguments set after construction: None
Attributes applied to .ctor: None
Условные атрибуты
Программисты все чаще используют атрибуты благодаря простоте их создания, применения и отражения. Атрибуты позволяют также снабдить код аннотациями, одновременно реализуя другие богатые возможности. В последнее время атрибуты часто используются при написании и отладке кода. Например, анализатор кода для Microsoft Visual Studio (FxCopCmd.exe) предлагает атрибут System. Diagnostics. CodeAnalysis .SuppressMessageAttribute, применимый к типам и членам и подавляющий сообщения о нарушении правила определенного инструмента статического анализа. Этот атрибут ищется только кодом анализатора, при нормальном ходе выполнения программы он игнорируется. Если вы не анализируете код, наличие в метаданных атрибута SuppressMessage просто увеличивает объем метаданных, что не лучшим образом сказывается на производительности. Соответственно, хотелось бы, чтобы компилятор задействовал этот атрибут только в случаях, когда вы собираетесь воспользоваться инструментом анализа кода.
Класс атрибута, к которому применен атрибут System. Diagnostic s . ConditionalAttribute, называется классом условного атрибута (conditional attribute). Пример:
//#define TEST #define VERIFY
using System;
using System.Diagnostics;
[Conditional("TEST")][Conditional("VERIFY")] public sealed class CondAttribute : Attribute {
}
[Cond]
public sealed class Program { public static void Main() {
Console.WriteLine("CondAttribute is {0}applied to Program type.",
Attribute.IsDefined(typeof(Program), typeof(CondAttribute)) ? "" : "not ");
}
}
Обнаружив, что был применен экземпляр CondAttribute, компилятор помещает в метаданные информацию об атрибуте, только если при компиляции кода был определен идентификатор TEST или VERIFY. При этом метаданные определения и реализации класса атрибута все равно останутся в сборке.
Глава 19. Null-совместимые значимые типы
Как известно, переменная значимого типа не может принимать значение null; ее содержимым всегда является значение соответствующего типа. Именно поэтому типы и называют значимыми. Но в некоторых ситуациях такой подход создает проблемы. Например, при проектировании базы данных тип данных столбца можно определить как 32-разрядное целое, что в FCL соответствует типу Int32. Однако в столбце базы может отсутствовать значение, что соответствует значению null, и это — вполне стандартная ситуация. А это создаст проблемы при работе с базой данных средствами .NET Framework, ведь общеязыковая среда (CLR) не позволяет представить значение типа Int32 как null.
ПРИМЕЧАНИЕ
Адаптеры таблиц MicrosoftADO.NET поддерживают типы, допускающие присвоение null. Но, ксожалению, типы в пространстве имен System.Data.SqlTypes не замещаются null-совместимыми типами отчасти из-за отсутствия однозначного соответствия между ними. К примеру, тип SqlDecimal допускает максимум 38 разрядов, в то время как обычный тип Decimal — только 29. А тип SqIString поддерживает собственные региональные стандарты и порядок сравнения, чего не скажешь о типе String.
Вот еще один пример: в Java класс j ava. util. Date относится к ссылочным типам, а значит, его переменные допускают присвоение значения null. В то же время в CLR тип System.DateTime является значимым и подобного присвоения не допускает. Если написанному на Java приложению потребуется передать информацию о дате и времени веб-службе на платформе CLR, возможны проблемы. Ведь если Java- приложение отправит значение null, CLR просто не будет знать, что с ним делать.
Чтобы исправить ситуацию, в Microsoft разработали для CLR null-совместимые значимые типы (nullable value type). Чтобы понять, как они работают, познакомимся с определенным в FCL классом System. Nullable<T>. Вот логическое представление реализации этого класса:
[Serializable, StгuctLayout(LayoutKind.Sequential)] public struct Nullable<T> where T : struct {
// Эти два поля представляют состояние
private Boolean hasValue = false; // Предполагается наличие null internal T value = default(T); // Предполагается, что все биты
// равны нулю
public Nullable(T value) { this.value = value; this.hasValue = true;
}
public Boolean HasValue { get { return hasValue; } }
public T Value { get {
if (IhasValue) {
throw new InvalidOperationException(
"Nullable object must have a value.");
>
return value;
>
}
public T GetValueOrDefaultQ { return value; }
public T GetValueOrDefault(T defaultValue) { if (IHasValue) return defaultValue; return value;
}
public override Boolean Equals(Object other) { if (IHasValue) return (other == null); if (other == null) return false; return value.Equals(other);
}
public override int GetHashCode() { if (IHasValue) return 0; return value.GetHashCodeQ;
}
public override string ToString() { if (IHasValue) return return value.ToString();
}
public static implicit operator Nullable<T>(T value) { return new Nullable<T>(value);
}
public static explicit operator T(Nullable<T> value) { return value.Value;
}
Как видите, этот класс реализует значимый тип, который может принимать значение null. Так как Nullable<T> также относится к значимым типам, его экземпляры достаточно производительны, поскольку экземпляры могут размещаться в стеке, а их размер совпадает с размером исходного типа, к которому приплюсован размер поля типа Boolean. Имейте в виду, что в качестве параметра Т типа Nullable могут использоваться только структуры — ведь переменные ссылочного типа и так могут принимать значение null.
Итак, чтобы использовать в коде null-совместимый тип Int32, вы пишете конструкцию следующего вида:
Nullable<Int32> х = 5;
Nullable<Int32> у = null;
Console.WriteLine("x: HasValue={0}, Value={l}", x.HasValue, x.Value);
Console.WriteLine("y: HasValue={0}, Value={l}“, у.HasValue, у.GetValueOrDefault());
После компиляции и запуска этого кода будет получен следующий результат:
х: HasValue=True, Value=5 у: HasValue=False, Value=0
Поддержка в C# null-совместимых значимых типов
В приведенном фрагменте кода для инициализации двух переменных х и у типа Nullable<Int32> используется достаточно простой синтаксис. Дело в том, что разработчики C# старались интегрировать в язык null-совместимые значимые типы, сделав их полноправными членами соответствующего семейства типов. В настоящее время C# предлагает достаточно удобный синтаксис для работы с такими типами. Переменные х и у можно объявить и инициализировать прямо в коде, воспользовавшись знаком вопроса:
Int32? х = 5;
Int32? у = null;
В C# запись Int32? аналогична записи Nullable<Int32>. При этом вы можете выполнять преобразования, а также приведение null-совместимых экземпляров к другим типам. Язык C# поддерживает возможность применения операторов к экземплярам null-совместимых значимых типов. Вот несколько примеров.
private static void ConversionsAndCastingQ {
// Неявное преобразование из типа Int32 в Nullable<Int32>
Int32? а = 5;
// Неявное преобразование из 'null' в Nullable<Int32>
Int32? b = null;
// Явное преобразование Nullable<Int32> в Int32 Int32 с = (Int32) a;
// Прямое и обратное приведение примитивного типа // в null-совместимый тип
Double? d = 5; // Int32->Double? (d содержит 5.0 в виде double)
Double? е = b; // Int32?->Double? (e содержит null)
}
Еще C# позволяет применять операторы к экземплярам null-совместимых типов.
Вот несколько примеров:
private static void Operators() {
Int32? a = 5;
Int32? b = null;
// Унарные операторы (+ ++---! ~) a++; // a = 6 b = -b; // b = null
// Бинарные операторы (+-*/%&|Л<<>>) a=a+3; // a = 9 b=b*3;//b= null;
// Операторы равенства (== !=) if (a == null) { /* нет */ } else { /* да */ }
if (b == null) { /* да */ } else { /* нет */ }
if (a != b) { /* да */ } else { /* нет */ }
// Операторы сравнения (о <= >=)
if (а < b) { /* нет */ } else { /* да */ }
}
Вот как эти операторы интерпретирует С#:
□ Унарные операторы (+++, Если операнд равен null, результат тоже
равен null.
□ Бинарные операторы (+, *, /, %, &, |, Л, <<, >>). Результат равен значению null,
если этому значению равен хотя бы один операнд. Исключением является случай воздействия операторов & и | на логический операнд ?. В результате поведение этих двух операторов совпадает с тернарной логикой SQL. Если ни один из операндов не равен null, операция проходит в обычном режиме, если же оба операнда равны null, в результате получаем null. Особая ситуация возникает в случае, когда значению null равен только один из операндов. В следующей таблице показаны возможные результаты, которые эти операторы дают для всех возможных комбинаций значений true, false и null.
□ Операторы равенства (==, ! =). Если оба операнда имеют значение null, они равны. Если только один из них имеет это значение, операнды не равны. Если ни один из них не равен null, операнды сравниваются на предмет равенства.
□ Операторы сравнения (<, >, <=, >=). Если значение null имеет один из операндов, в результате получаем значение false. Если ни один из операндов не имеет значения null, следует сравнить их значения.
Операнд 1 -> Операнд 2 1 | true | false | null |
True | & = true | & = false | & = null |
| = true | | = true | | = true | |
False | & = false | & = false | & = false |
| = true | | = false | | = null | |
Null | & = null | & = false | & = null |
| = true | | = null | | = null |
|
Следует учесть, что для операций с экземплярами null-совместимых типов генерируется большой объем кода. К примеру, рассмотрим метод:
private static Int32? NullableCodeSize(Int32? a, Int32? b) { return a + b;
}
В результате компиляции будет создан большой объем IL-кода, вследствие чего операции с null-совместимым и типами выполняются медленнее аналогичных операций с другими типами. Вот эквивалент этого кода на С#:
private static Nullable<Int32> NullableCodeSize(Nullable<Int32> a,
Nullable<Int32> b) {
Nullable<Int32> nullablel = a;
Nullable<Int32> nullable2 = b;
if (!(nullablel.HasValue & nullable2.HasValue)) { return new Nullable<Int32>();
}
return new Nullable<Int32>(
nullablel.GetValueOrDefault() + nullable2.GetValueOrDefault());
Напоследок напомню о возможности определения ваших собственных значимых типов, перегружающих упомянутые операторы. О том, как именно это делается, мы говорили в главе 8. Если воспользоваться null-совместимым экземпляром вашего собственного значимого типа, компилятор поймет это правильно и вызовет перегруженный оператор. Предположим, имеется значимый тип Point, следующим образом определяющий перегрузку операторов == и ! =:
using System;
internal struct Point { private Int32 m_x, my;
public Point(Int32 x, Int32 y) { m_x = x; m_y = y; } public static Boolean operator==(Point pi, Point p2) {
return (p1.m_x == p2.m_x) && (p1.m_y == p2.m_y);
}
public static Boolean operator!=(Point p1, Point p2) { return !(p1 == p2);
}
}
Воспользовавшись в этот момент null-совместимыми экземплярами типа Point, вы заставите компилятор вызвать перегруженные операторы:
internal static class Program {
public static void Main() {
Point? pi = new Point(1, 1);
Point? p2 = new Point(2, 2);
Console.WriteLine("Are points equal? " + (p1 == p2).ToString()); Console.WriteLine("Are points not equal? " + (p1 != p2).ToString());
}
}
После запуска этого кода я получил следующий результат:
Are points equal? False Are points not equal? True
Оператор объединения null-совместимых значений
В C# существует оператор объединения null-совместимых значений (null-coalescing operator). Он обозначается знаками ?? и работает с двумя операндами. Если левый операнд не равен null, оператор возвращает его значение. В противном случае возвращается значение правого операнда. Оператор объединения null-совместимых значений удобен при задании предлагаемого по умолчанию значения переменной.
Основным преимуществом этого оператора является поддержка как ссылочных, так и null-совместимых значимых типов. Следующий код демонстрирует его работу:
private static void NullCoalescingOperator() {
Int32? b = null;
// Приведенная далее инструкция эквивалентна следующей:
// x = (b.HasValue) ? b.Value : 123 Int32 x = b ?? 123;
Console.WriteLine(x); // "123"
// Приведенная далее в инструкции строка эквивалентна следующему коду:
// String temp = GetFilename();
// filename = (temp != null) ? temp : "Untitled";
String filename = GetFilename() ?? "Untitled";
}
Некоторые пользователи считают оператор объединения null-совместимых значений всего лишь синтаксическим сокращением для оператора ?. Однако оператор ? ? предоставляет два важных синтаксических преимущества. Во-первых, он лучик; работает с выражениями:
Func<String> f = () => SomeMethod() ?? "Untitled";
Прочитать и понять эту строку намного проще, чем следующий фрагмент кода, требующий присваивания переменных и использования нескольких операторов:
Func<String> f = () => { var temp = SomeMethod(); return temp != null ? temp : "Untitled";};
Во-вторых, оператор ?? лучше работает в некоторых сложных ситуациях:
String s = SomeMethodlQ ?? SomeMethod2() ?? "Untitled";
Согласитесь, эту строку прочитать и понять гораздо проще, чем следующий фрагмент кода:
String s;
var sml = SomeMethodl(); if (sml != null) s = sml; else {
var sm2 = SomeMethod2(); if (sm2 != null) s = sm2; else s = "Untitled";
}
Поддержка в CLR null-совместимых значимых типов
В CLR существует встроенная поддержка null-совместимых значимых типов. Она предусматривает упаковку и распаковку, а также вызов метода GetType и интерфейсных методов. Все это призвано обеспечить более тесную интеграцию null-совместимых значимых типов в CLR. В результате типы ведут себя более естественно и лучше соответствуют ожиданиям большинства разработчиков. Рассмотрим поддержку этих типов в CLR более подробно.
Упаковка null-совместимых значимых типов
Представим переменную типа Nullable<Int32>, которой логически присваивается значение null. Для передачи этой переменной методу, ожидающему ссылки на тип Object, ее следует упаковать и передать методу ссылку на упакованный тип
Nullable<Int32>. Однако при этом в метод будет передано отличное от null значение, несмотря на то что тип Nullable<Int32> содержит null. Эта проблема решается в CLR при помощи специального кода, который при упаковке null-совместимых типов создает иллюзию их принадлежности к обычным типам.
При упаковке экземпляра Nullable<T> проверяется его равенство null и в случае положительного результата вместо упаковки возвращается null. В противном случае CLR упаковывает значение экземпляра. Другими словами, тип Nullable<Int32> со значением 5 упаковывается в тип Int32 с аналогичным значением. Следующий код демонстрирует такое поведение:
// После упаковки Nullable<T> возвращается null или упакованный тип T Int32? n = null;
Object о = n; // о равно null
Console.WriteLine("o is null={0}", о == null); // "True" n = 5;
о = n; // о ссылается на упакованный тип Int32
Console.WriteLine("o's type={0}", o.GetTypeQ); // "System.Int32"
Распаковка null-совместимых значимых типов
В CLR упакованный значимый тип Т распаковывается в Т или в Nullable<T>. Если ссылка на упакованный значимый тип равна null и выполняется распаковка в тип Nullable<T>, CLR присваивает Nullable<T> значение null. Пример:
// Создание упакованного типа Int32 Object о=5;
// Распаковка этого типа в Nullable<Int32> и в Int32 Int32? а = (Int32?) о; // а = 5 Int32 b = (Int32) о; // b = 5
// Создание ссылки, инициализированной значением null о = null;
// "Распаковка" ее в Nullable<Int32> и в Int32 а = (Int32?) о; // а = null b = (Int32) о; // NullReferenceException
Вызов метода GetType через null-совместимый значимый тип
При вызове метода GetType для объекта типа Nullable<T> CLR возвращает тип Т вместо Nullable<T>. Пример:
Int32? X = 5;
// Эта строка выводит "System.Int32", а не "System.Nullable<Int32>" Console.WriteLine(x.GetType());
Вызов интерфейсных методов через null-совместимый значимый тип
В приведенном далее фрагменте кода переменная п типа Nullable<Int32> приводится к интерфейсному типу ICompanable<Int32>. Но тип Nullable<T> в отличие от типа Int32 не реализует интерфейс ICompanable<Int32>. Тем не менее код успешно компилируется, а механизм верификации CLR считает, что код прошел проверку, чтобы вы могли использовать более удобный синтаксис.
Int32? п = 5;
Int32 result = ((IComparable) п).СотрагеТо(Б); // Компилируется
// и выполняется
Console.WriteLine(result); // 0
Без подобной поддержки со стороны CLR пришлось бы писать громоздкий код вызова интерфейсного метода через null-совместимый значимый тип. Для вызова метода потребовалось бы приведение распакованного значимого типа перед приведением к интерфейсу:
Int32 result = ((IComparable) (Int32) n).СотрагеТо(Б); // Громоздкий код
ЧАСТЬ IV
Ключевые механизмы
Глава 20. Исключения и управление состоянием .. .496
Глава 21. Автоматическое управление памятью (уборка мусора) 554
Глава 22. Хостинг CLR и домены приложений....................... 606
Глава 23. Загрузка сборок и отражение.................................. 636
Глава 24. Сериализация........................................................... 666
Глава 25. Взаимодействие
с компонентами WinRT............................................................ 698
Глава 20. Исключения и управление состоянием
Эта глава посвящена обработке исключений (exception handling), хотя в ней будут затронуты и другие темы. Процесс обработки исключения состоит из нескольких шагов. Для начала следует определить, что именно считать ошибкой. После этого нужно выяснить, когда возникает ошибка, и решить, как от нее избавиться. На этом этапе возникает вопрос о состоянии системы, так как ошибки обычно возникают в самое неподходящее время. Скорее всего, ваш код в этот момент будет находиться в некоем переходном состоянии и вам потребуется вернуть его в состояние, существовавшее до возникновения ошибки. Разумеется, мы также выясним, каким образом код дает понять о том, что с ним что-то не так.
На мой взгляд, обработка исключений является самым слабым местом CLR, и именно поэтому разработчикам бывает так трудно писать управляемый код. В последнее время специалисты Microsoft внесли значительные улучшения в этот аспект, но до хорошей, надежной системы все еще достаточно далеко. О том, что именно было сделано в данном направлении, мы поговорим при рассмотрении необработанных исключений, областей ограниченного выполнения, контрактов кода, средств создания оберток для исключений во время выполнения, неперехваченных исключений и т. п.
Определение «исключения»
Конструируя тип, мы заранее пытаемся представить, в каких ситуациях он будет использоваться. В качестве имени типа обычно выбирается существительное, например FileStream или StringBuilder. Затем задаются свойства, события, методы и т. п. Форма определения этих членов (типы данных свойств, параметры методов, возвращаемые значения и т. и.) становится программным интерфейсом типа. Именно члены определяют допустимые действия с типом и его экземплярами. Для их имен обычно выбираются глаголы, например Read, Write, Flush, Append, Insert, Remove и т. п. Если член не может решить возложенную на него задачу, программа должна выдать исключение. Рассмотрим следующее определение класса:
internal sealed class Account {
public static void Transfer(Account from. Account to. Decimal amount) { from -= amount; to += amount;
}
}
Метод Тransfer принимает два объекта Account и значение Decimal, определяя, сколько средств переводится с одного счета на другой. Очевидно, что этот метод должен вычитать деньги с одного счета и прибавлять их к другому. Но есть ряд обстоятельств, которые могут помешать его работе. Например, аргумент from или to может иметь значение null; аргументы from или to могут не соответствовать открытым счетам; на счету, с которого предполагается взять деньги, может оказаться недостаточно средств; на целевом счету может оказаться так много денег, что перевод дополнительной суммы станет причиной переполнения; аргумент amount может быть равен 0, иметь отрицательное значение или иметь более двух знаков после запятой.
При вызове метода Transfer следует учитывать все перечисленные ситуации и при выявлении любой из них оповещать вызывающий код, генерируя исключение. Обратите внимание, что возвращаемое методом значение принадлежит к типу void. То есть метод Тransfer просто завершает свою работу, если завершение происходит в обычном режиме, или генерирует исключение в противном случае.
Объектно-ориентированное программирование обеспечивает высокую эффективность труда разработчиков, так как позволяет писать, например, такой код:
Boolean f = "left".Substring(l, 1).ToUpper().EndsWith("E"); // true
Здесь я реализую свои намерения, объединяя несколько операций1. Этот код легко читается и редактируется, так как его назначение очевидно. Мы берем строку, выделяем ее часть, приводим символы этой части к верхнему регистру и смотрим, заканчивается ли выделенный фрагмент символом "Е". При этом делается допущение, что все упомянутые операции успешно завершаются. Хотя, разумеется, от ошибок никто не застрахован. Соответственно, с ними нужно что-то делать. Существует множество объектно-ориентированных средств — конструкторы, инструменты просмотра/задания свойств, добавления/удаления событий, вызовы перегрузки операторов, вызовы операторов преобразования типа, — которые не умеют возвращать код ошибки. Но даже они должны каким-то способом сообщать о ее наличии. В .NET Framework и всех поддерживаемых этой платформой языках программирования для этой цели существует специальный механизм, называемый обработкой исключений (exception handling).
ВНИМАНИЕ
Некоторые разработчики ошибочно считают, что исключения зависят от частоты возникновения некоторого явления. К примеру, разработчик метода чтения файла может сказать: «Читая файл, вы в итоге достигнете его конца. Так как это случается всегда, я заставлю мой метод Read в этот момент возвращать специальное значение. И тогда генерировать исключение не понадобится». Но так считает разработчик, создающий метод Read, а не тот, кто этим методом потом пользуется.
Методы расширения в C# позволяют строить цепочки из целого набора методов.
На момент создания метода невозможно предугадать все ситуации, в которых он будет вызываться. Соответственно, нельзя предсказать, насколько частыми станут попытки прочитать файл до конца. Более того, так как большинство файлов содержит структурированные данные, вряд ли чтение последних фрагментов будет частым событием.
Механика обработки исключений
В этом разделе рассмотрены конструкции языка С#, предназначенные для обработки исключений, хотя мы и не будем особо вдаваться детали. Вам важно понять, когда и каким образом применяется обработка исключений. Подробную же информацию по данной теме вы найдете в документации на .NET Framework и спецификации языка С#. Следует также упомянуть, что в основе обработки исключений в .NET Framework лежит структурная обработка исключений (Structured Exception Handling, SEH) Windows. SEH рассматривается во многих источниках, в том числе в моей книге «Windows via C/C++» (Microsoft Press, 2007).
Рассмотрим код, демонстрирующий стандартное применение механизма обработки исключений. Он дает представление о виде и предназначении блоков обработки исключений. В комментариях дано формальное описание блоков try, catch и finally.
private void SomeMethodQ { try {
// Код, требующий корректного восстановления // или очистки ресурсов
}
catch (InvalidOperationException) {
// Код восстановления работоспособности // после исключения InvalidOperationException
}
catch (IOException) {
// Код восстановления работоспособности // после исключения IOException
}
catch {
// Код восстановления работоспособности после остальных исключений.
// После перехвата исключений их обычно генерируют повторно
// Эта тема будет рассмотрена позже
throw;
}
finally {
// Здесь находится код, выполняющий очистку ресурсов // после операций, начатых в блоке try. Этот код // выполняется ВСЕГДА вне зависимости от наличия исключения }
// Код, следующий за блоком finally, выполняется, если в блоке try // не генерировалось исключение или если исключение было перехвачено // блоком catch, а новое не генерировалось
}
Мы рассмотрели один из возможных способов обработки исключений при помощи блоков. Обычно все выглядит намного проще. В большинстве случаев можно обойтись всего двумя блоками, например блоком try с соответствующим ему блоком finally или же парой try и catch. Такой сложный пример я выбрал для демонстрации возможных комбинаций.
Блок try
В блок try помещается код, требующий очистки ресурсов и/или восстановления после исключения. Код очистки содержится в блоке finally. В блоке try может располагаться также код, приводящий к генерации исключения. Код же восстановления вставляют в один или несколько блоков catch. Один блок catch соответствует одному событию, после которого по вашим предположениям может потребоваться восстановление приложения. Блок try должен быть связан хотя бы с одним блоком catch или finally; сам по себе он не имеет смысла, и C# запрещает такие определения.
ВНИМАНИЕ
Иногда разработчики спрашивают, какой объем кода следует размещать внутри блока try. Ответ на этот вопрос зависит от управления состоянием. Если внутри блока try вы собираетесь выполнять набор операций, каждая из которых может стать причиной исключения одного и того же типа, но при этом способы обработки каждого исключения разные, имеет смысл создать для каждой операции собственный блок try.
Блок catch
В блок catch помещают код, который должен выполняться в ответ на исключение. Блок try может быть связан как с набором блоков catch, так и не ассоциироваться ни с одним таким блоком. Если код в блоке try не порождает исключение, CLR никогда не переходит к выполнению кода в соответствующем блоке catch. Поток просто пропускает их, сразу переходя к коду блока finally (если таковой, конечно, существует). Выполнив код блока finally, поток переходит к инструкции, следующей за этим блоком.
Выражение в скобках после ключевого слова catch называется типом исключения (catch type). В C# эту роль играет тип System.Exception и его производные. В предыдущем примере первые два блока catch обрабатывали исключения типа InvalidOperationException (или их производные) и IOException (или, опять же, их производные). В последнем блоке (для которого не был явно указан тип исключения) обрабатывались все остальные виды исключений. Это эквивалентно блоку catch для исключений типа System.Exception, не считая того, что информация исключения в коде, заключенном в фигурные скобки, недоступна.
ПРИМЕЧАНИЕ
При отладке блока catch в Microsoft Visual Studio для просмотра текущего исключения следует добавить в окно контрольных значений специальную переменную $exception.
Поиск подходящего блока catch в CLR осуществляется сверху вниз, поэтому наиболее конкретные обработчики должны находиться в начале списка. Сначала следуют потомки с наибольшей глубиной наследования, потом — их базовые классы (если таковые имеются) и, наконец, — класс System. Exception (или блок с неуказанным типом исключений). В противном случае компилятор сообщит об ошибке, так как более узкоспециализированные блоки в такой ситуации окажутся для него недостижимыми.
Исключение, сгенерированное при выполнении кода блока t гу (или любого вызванного этим блоком метода), инициирует поиск блоков catch соответствующего типа. При отсутствии совпадений CLR продолжает просматривать стек вызовов в поисках типа исключения, соответствующего данному исключению. Если при достижении вершины стека блок catch нужного типа обнаружен не будет, исключение считается необработанным. Эту ситуацию мы рассмотрим чуть позже.
При обнаружении блока catch нужного типа CLR исполняет все внутренние блоки finally, начиная со связанного с блоком try, в котором было вброшено исключение, и заканчивая блоком catch нужного типа. При этом ни один блок finally не выполняется до завершения действий с блоком catch, обрабатывающим исключение.
После того как код внутренних блоков finally будет выполнен, исполняется код из обрабатывающего блока catch. Здесь выбирается способ восстановления после исключения. Затем можно выбрать один из трех вариантов действий:
□ еще раз сгенерировать то же исключение для передачи информации о нем коду,
расположенному выше в стеке;
□ сгенерировать исключение другого типа для передачи дополнительной информации коду, расположенному выше в стеке;
□ позволить программному потоку выйти из блока catch естественным образом.
О том, в каких ситуациях следует выбрать каждый из этих способов, мы поговорим немного позже. При выборе первого или второго варианта действий CLR работает по уже рассмотренной схеме: просматривает стек вызовов в поисках блока catch, тип которого соответствует типу сгенерированного исключения.
В последнем же случае происходит переход к блоку finally (если он, конечно, существует). После выполнения всего содержащегося в нем кода управление переходит к расположенной после блока finally инструкции. Если блок finally отсутствует, поток переходит к инструкции, расположенной за последним блоком catch.
В C# после типа перехватываемого исключения можно указать имя переменной, которая будет ссылаться на сгенерированный объект, потомок класса System. Exception. В коде блока catch эту переменную можно использовать для получения информации об исключении (например, данных трассировки стека, приведшей к исключению). Объект, на который ссылается переменная, в принципе можно редактировать, но я рекомендую рассматривать его как предназначенный только для чтения. Впрочем, подробный разговор о типе Exception и манипуляциях им вынесен в отдельный раздел.
ПРИМЕЧАНИЕ
Можно создать событие FirstChanceException класса AppDomain и получать информацию об исключениях еще до того, как CLR начнет искать их обработчики. Подробно эта тема рассматривается в главе 22.
Блок finally
Код блока finally выполняется всегда[12]. Обычно этот код производит очистку после выполнения блока try. Если в блоке try был открыт некий файл, блок finally должен содержать закрывающий этот файл код:
private void ReadData(String pathname) {
FileStream fs = null; try {
fs = new FileStream(pathname, FileMode.Open);
// Обработка данных в файле
}
catch (IOException) {
// Код восстановления после исключения IOException }
finally {
// Файл обязательно следует закрыть if (fs != null) fs.Close();
}
}
Если код блока try выполняется без исключений, файл закрывается. Впрочем, поскольку даже исключение не помешает выполнению кода в блоке finally, файл гарантированно будет закрыт. А если поместить инструкцию закрытия файла после блока finally, в случае неперехваченного исключения файл останется открытым (до следующего прохода уборщика мусора).
Блок try может существовать и без блока finally, ведь иногда его код просто не требует последующей очистки. Однако если вы решили создать блок finally, его следует поместить после всех блоков catch. И помните, что одному try может соответствовать только один блок finally.
Достигнув конца блока finally, поток переходит к инструкции, расположенной после этого блока. Запомните, что в блок finally помещается код для выполнения завершающей очитски. И он должен выполнять только те действия, которые необходимы для отмены операций, начатых в блоке try. Код блоков catch и finally следует делать по возможности коротким (ограничиваясь одной или двумя строками) и по возможности работающим без исключений. Однако иногда случается так, что источником исключения становится код восстановления или код очистки. Обычно это указывает на наличие серьезных ошибок.
Всегда существует вероятность того, что во время выполенния кода восстановления или очистки произойдет сбой, и будет выдано исключение. Впрочем, такая ситуация маловероятна, а ее возникновение свидетельствует о возникновении очень серьезных проблем в программе (скорее всего, о повреждении текущего состояния). Если источником исключения становятся блоки catch или finally, CLR продолжает работу как в случае, когда исключение генерируется после блока finally. Просто при этом теряется информация о первом исключении, вброшенном в блоке try. Скорее всего (и даже желательно), это новое исключение останется необработанным. После этого CLR завершает процесс, уничтожая поврежденное состояние. Продолжение работы приложения в подобном случае; привело бы к непредсказуемым результатам и, вероятно, к появлению дефектов в системе безопасности.
С моей точки зрения, для механизма обработки исключений следовало бы выбрать другие ключевые слова. Ведь программисту нужно всего лишь выполнить фрагмент кода. А если что-то пойдет не так, либо восстановить приложение после ошибки и двигаться дальше, либо вернуться в состояние до возникновения проблем и сообщить о неполадках. Программистам также нужно гарантированное выполнение завершающей очистки. Слева показан код, правильный с точки зрения компилятора, а справа — синтаксис, который предпочел бы видеть я:
void Method() { | void Method() { |
try { | try { |
} | } |
|
catch (XxxException) { | handle (XxxException) { |
} | } |
catch (YyyException) { | handle (YyyException) { |
} | } |
catch { | compensate { |
...; throw; | .. .; throw; |
} | > |
finally { | cleanup { |
} | > |
} | } |
|
CLS-совместимые и CLS-несовместимые исключения
Все языки программирования, ориентированные на CLR, должны поддерживать создание объектов класса Exception, так как этого требует общеязыковая спецификация (Common Language Specification, CLS). Но на самом деле, CLR разрешает создавать экземпляры любого типа, в результате в некоторых языках появляются несовместимые с CLS исключения типа String, Int32 или DateTime. Компилятор C# разрешает генерировать только объекты, производные от класса Exception, в то время как в других языках это ограничение отсутствует.
Многие программисты не знают, что для передачи исключения можно генерировать объект любого типа, поэтому они пользуются только объектами, производными от класса Exception. До выхода версии CLR 2.0 в блоках catch перехватывались только CLS-совместимые исключения. Если метод на C# вызывал метод, написанный на другом языке, и тот генерировал CLS-несовместимое исключение, его было невозможно перехватить, что чревато нарушением защиты.
Начиная с версии 2.0, в CLR появился класс RuntimeWrappedException, определенный в пространстве имен System. Runtime. CompilerServices. Являясь производным от класса Exception, он представляет собой CLS-совместимый тип исключений. Этот класс обладает закрытым полем типа Ob j ect, к которому можно обратиться через предназначенное только для чтения свойство Wrapped Exception того же класса. В CLR 2.0 при генерации CLS-несовместимого исключения автоматически создается экземпляр класса RuntimeWrappedException, закрытому полю которого присваивается ссылка на вброшенный объект. Таким способом несовместимые с CLS исключения превращаются в CLS-совместимые. В итоге любой код, умеющий перехватывать исключения типа Exception, будет перехватывать и все остальные исключения, что устраняет угрозу безопасности.
До версии 2.0 перехват CLS-несовместимых исключений осуществлялся с помощью примерно такого кода:
private void SomeMethod() { try {
// Внутрь блока try помещают код, требующий корректного // восстановления работоспособности или очистки ресурсов
}
catch (Exception е) {
// До C# 2.0 этот блок перехватывал только CLS-совместимые исключения // В C# 2.0 этот блок научился перехватывать также // CLS-несовместимые исключения
throw; // Повторная генерация перехваченного исключения
}
catch {
// Во всех версиях C# этот блок перехватывает // и совместимые, и несовместимые с CLS исключения throw; // Повторная генерация перехваченного исключения
}
}
Узнав, что CLR поддерживает теперь оба вида исключений, некоторые разработчики стали писать два блока catch (как показано в предыдущем фрагменте кода), чтобы перехватывать исключения обоих видов. Если этот код перекомпилировать для CLR 2.0, второй блок catch никогда не будет выполняться, а компилятор выдаст предупреждение (CS1058: предыдущий блок catch уже перехватывает все исключения. Остальные объекты заключаются в обертку класса System. Runtime. CompilerServices.RuntimeWrappedException):
CS1058: A previous catch clause already catches all exceptions. All non-exceptions thrown will be wrapped in a System.Runtime.CompilerServices.RuntimeWrappedException
Есть два пути переноса кода более ранних версий в .NET Framework 2.0. Во- первых, можно объединить два блока catch. Именно так рекомендуется действовать. Однако можно также сообщить CLR, что код вашей сборки будет работать по «старым» правилам, то есть что блоки catch (Exception) не должны перехватывать экземпляры нового класса RuntimeWrappedException. Вместо этого, среда CLR должна извлечь из обертки CLS-несовместимый объект и вызывать ваш код только при наличии в нем блока catch, в котором не определено никакого типа. Чтобы сообщить CLR об этом, к сборке нужно применить экземпляр RuntimeCompatibilityAttribute, например, так:
using System.Runtime.CompilerServices;
[assembly:RuntimeCompatibility(WrapNonExceptionThrows = false)]
ПРИМЕЧАНИЕ
Этот атрибут действует на уровне целой сборки. В одной сборке нельзя совмещать исключения в обертке и без нее. Нужно соблюдать особую осторожность при добавлении в сборку нового кода (который ожидает от CLR исключений в обертке класса System.Runtime.CompilerServices.RuntimeWrappedException), где есть старый код (в котором CLR не помещает исключения в обертку).
Класс System.Exception
CLR позволяет генерировать в качестве исключений экземпляры любого типа — от Int32 до String. Но в Microsoft решили, что не стоит заставлять все языки генерировать и перехватывать исключения произвольного типа. Соответственно, был создан тип System. Exception. Именно исключения этого типа и его производных должны перехватываться во всех CLS-совместимых языках программирования. CLS-совместимыми называются типы исключений, производные от типа System. Exception. Компиляторы C# и многих других языков позволяют коду генерировать только CLS-совместимые исключения.
System. Exception — очень простой тип с набором свойств, перечисленных в табл. 20.1. Скорее всего, обращаться к этим свойствам в своем коде вам никогда не придется. Их ищут в отчете отладчика или в аварийном дампе памяти после прекращения работы приложения из-за необработанного исключения.
Таблица 20.1. Открытые свойства типа System.Exception
продолжение
|
Таблица 20.1 (продолжение)
|
|
Хотелось бы подробнее поговорить о доступном только для чтения свойстве StackTnace класса System. Exception. Блок catch может прочитать его для получения информации о том, какой именно метод стал источником исключения. Эта информация может быть весьма ценной для поиска объекта, ставшего источником исключения, и последующего исправления кода. При обращении к этому свойству вы фактически обращаетесь к коду в CLR, поскольку свойство не просто возвращает строку. При создании объекта типа, производного от Exception, свойству StackTrace присваивается значение null. И соответственно, при попытке прочитать свойство вы получили бы не результат трассировки стека, a null.
При появлении исключения CLR делает запись с указанием места его возникновения. Когда блок catch получает исключение, CLR записывает, где именно оно было обнаружено. Если внутри блока catch обратиться к свойству StackTrace объекта, сгенерированного при появлении исключения, реализующий это свойство код обратится к CLR, где и будет создана строка, содержащая имена всех методов от точки, в которой было вброшено исключение, до точки, где оно было перехвачено.
ВНИМАНИЕ
При появлении исключения CLR обнуляет его начальную точку. То есть CLR запоминает только место появления самого последнего исключения.
Следующий код генерирует то же исключение, которое было перехвачено, и заставляет CLR обнулить начальную точку:
private void SomeMethodQ { try { ... } catch (Exception e) {
throw e; // CLR считает, что исключение возникло тут // FxCop сообщает об ошибке
}
}
В противоположность этому, при повторном вызове перехваченного исключения с помощью ключевого слова throw удаления из стека информации о начальной точке не происходит. Пример:
private void SomeMethodQ { try { ... } catch (Exception e) {
throw; // CLR не меняет информацию о начальной точке исключения.
// FxCop НЕ сообщает об ошибке
}
}
Эти два фрагмента кода отличаются только тем, где, по мнению CLR, было сгенерировано исключение. К сожалению, при первом или повторном вызове исключения Windows обнуляет стек с информацией о начальной точке. И в случае необработанного исключения в систему сбора информации об ошибках Windows уходят сведения о последнем вброшенном исключении, даже если CLR «знает», где именно было сгенерировано самое первое исключение. Это серьезно усложняет отладку приложений. Некоторым разработчикам подобная ситуация кажется недопустимой, поэтому они выбирают другой способ реализации кода, гарантирующий истинность информации о первоначальной точке возникновения исключения:
private void SomeMethodQ {
Boolean trySucceeds = false; try {
trySucceeds = true;
}
finally {
if ((trySucceeds) { /* код перехвата исключения */ }
}
Строка, возвращаемая свойством StackTrace, не включает в себя имен методов, расположенных в стеке вызова выше точки принятия исключения блоком catch. Для отслеживания всего стека с самого начала до момента обработки исключения используйте тип System.Diagnostics.StackTrace. Он определяет свойства и методы, дающую разработчикам возможность программно управлять трассировкой стека и составляющими его кадрами.
Существует несколько конструкторов, позволяющих получить объект StackT race. Некоторые из них строят кадры от начала потока до момента появления объекта StackTrace. Другие — инициализируют кадры объекта StackTrace, передавая ему в качестве аргумента объект, производный от типа Exception.
Если CLR обнаруживает для ваших сборок символические имена отладки (находящиеся в файлах с расширением pdb), строка, возвращаемая свойством StackTrace объекта System.Exception или методом ToString объекта System. Diagnostic s.StackTrace, содержит пути файлов исходного кода и номера строк. Эта информация чрезвычайно полезна для отладки.
В результатах трассировки стека можно обнаружить, что имена некоторых из вызывавшихся методов отсутствуют. Такая ситуация может возникнуть по двум причинам. Во-первых, в стеке содержится информация о том, куда должен вернуть управление поток, а не откуда произошло обращение. Во-вторых, JIT-компилятор может выполнять подстановку (inline) кода методов в вызывающий код, чтобы избежать слишком большого числа вызовов, и возвращать результат вызова только одного метода. Многие компиляторы (в том числе С#) предлагают переключатель командной строки /debug. При его использовании компилятор включает в результирующую сборку информацию, заставляющую JIT-компилятор прекратить подстановку методов. В результате трассировка стека становится более полной и содержательной в процессе отладки.
ПРИМЕЧАНИЕ
JIT-компилятор проверяет назначенный сборке атрибут System.Diagnostics. DebuggableAttribute. Компилятор C# назначает этот атрибут автоматически. Установка флага DisableOptimizations заставляет JIT-компилятор прекратить подстановку методов сборки. В C# флаг устанавливается переключателем командной строки /debug. Применив к методу настраиваемый атрибут System. Runtime. CompilerServices. MethodlmplAttribute, вы можете запретить подстановку какдля отладочной, так и для рабочей конфигурации. Вот пример определения метода, запрещающего подстановку:
using System;
using System.Runtime.CompilerServices; internal sealed class SomeType {
[Methodlmpl(MethodlmplOptions.NoInlining)] public void SomeMethod() {
Классы исключений, определенные в FCL
В библиотеке классов Framework Class Library определено множество типов исключений (являющихся потомками класса System. Exception). Типы, определенные в сборке MSCorLib.dll, иллюстрирует показанная далее иерархия; другие сборки содержат еще больше типов исключений (эта иерархия получена при помощи приложения, демонстрирующегося в главе 23).
System.Exception
System.AggregateException System.ApplicationException
System.Reflection.InvalidFilterCriteriaException System.Reflection.TargetException System.Reflection.TargetlnvocationException System.Reflection.TargetParameterCountException System.Threading.WaitHandleCannotBeOpenedException System.Diagnostics.T racing.EventSourceException System.InvalidTimeZoneException
System.10.IsolatedStorage.IsolatedStorageException System.Runtime.CompilerServices.RuntimeWrappedException System.SystemException
System.Threading.AbandonedMutexException System.AccessViolationException System.Reflection.AmbiguousMatchException System.AppDomainUnloadedException System.ArgumentException
System.ArgumentNullException System.ArgumentOutOfRangeException System.Globalization.CultureNotFoundException System.Text.DecoderFallbackException System.DuplicateWaitObjectException System.Text.EncoderFallbackException System.ArithmeticException
System.DivideByZeroException System.NotFiniteNumberException System.OverflowException System.ArrayTypeMismatchException System.BadlmageFormatException System.CannotUnloadAppDomainException System.ContextMarshalException
System.Security.Cryptography.CryptographicException
System.Security.Cryptography.CryptographicUnexpectedOperationException System.DataMisalignedException System.ExecutionEngineException System.Runtime.InteropServices.ExternalException System.Runtime.InteropServices.COMException System.Runtime.InteropServices.SEHException System.FormatException
System.Reflection.CustomAttributeFormatException System.Security.HostProtectionException
System.Security.Principal.IdentityNotMappedException System.IndexOutOfRangeException System.InsufficientExecutionStackException System.InvalidCastException
System.Runtime.InteropServices.InvalidComObjectException System.Runtime.InteropServices.InvalidOleVariantTypeException System.InvalidOperationException System.ObjectDisposedException System.InvalidProgramException System.10.IOException
System.10.DirectoryNotFoundException System.10.DriveNotFoundException System.10.EndOfStreamException System.10.FileLoadException System.10.FileNotFoundException System.10.PathTooLongException System.Collections.Generic.KeyNotFoundException System.Runtime.InteropServices.MarshalDirectiveException System.MemberAccessException System.FieldAccessException System.MethodAccessException System.MissingMemberException System.MissingFieldException System.MissingMethodException System.Resources.MissingManifestResourceException System.Resources.MissingSatelliteAssemblyException System.MulticastNotSupportedException System.NotlmplementedException System.NotSupportedException
System.PlatformNotSupportedException System.NullReferenceException System.OperationCanceledException
System.Threading.Tasks.TaskCanceledException System.OutOfMemoryException
System.InsufficientMemoryException System.Security.Policy.PolicyException System.RankException
System.Reflection.ReflectionTypeLoadException System.Runtime.Remoting.RemotingException
System.Runtime.Remoting.RemotingTimeoutException System.Runtime.InteropServices.SafeArrayRankMismatchException System.Runtime.InteropServices.SafeArrayTypeMismatchException System.Security.SecurityException System.Threading.SemaphoreFullException System.Runtime.Serialization.SerializationException System.Runtime.Remoting.ServerException System.StackOverflowException System.Threading.SynchronizationLockException System.Threading.ThreadAbortException System.Threading.ThreadlnterruptedException System.Threading.ThreadStartException System.Threading.ThreadStateException
System.TimeoutException
System.TypelnitializationException
System.TypeLoadException
System.DllNotFoundException System.EntryPointNotFoundException System.TypeAccessException System.TypeUnloadedException System.UnauthorizedAccessException
System.Security.AccessControl.PrivilegeNotHeldException System.Security.VerificationException System.Security.XmlSyntaxException System.Threading.Tasks.TaskSchedulerException System.T imeZoneNotFoundException
Специалисты Microsoft хотели сделать тип System. Exception базовым для всех исключений, а два других типа, System. SystemException и System. Applica- tionException, стали бы его непосредственными потомками. Кроме того, исключения, вброшенные CLR, стали бы производными от типа SystemException, в то время как исключения, появившиеся в приложениях, должны были наследовать от ApplicationException. Это дало бы возможность написать блок catch, перехватывающий как все CLR-исключения, так и все исключения приложений.
Однако на практике это правило соблюдается не полностью; некоторые исключения являются прямыми потомками типа Exception (IsolatedStorage- Exception), некоторые CLR-исключения наследуют от типа ApplicationException (TargetlnvocationException), а некоторые исключения приложений — от типа SystemException (FormatException). Из-за этой путаницы типы SystemException и ApplicationException не несут никакой особой смысловой нагрузки. В настоящее время в Microsoft подумывают вообще убрать их из иерархии классов исключений, но это невозможно, так как приведет к нарушению работы уже имеющихся приложений, в которых используются эти классы.
Генерирование исключений
При реализации своего метода следует сгенерировать исключение, если метод не в состоянии решить возложенную на него задачу. При этом необходимо учитывать два фактора.
Во-первых, следует понять, к какому производному от типа Exception типу будет относиться ваше исключение. Выбирать следует осмотрительно. Подумайте о том, каким образом код, расположенный выше по стеку вызовов, сможет получать информацию о неудачной работе метода, чтобы выполнить восстановительные операции. Можно воспользоваться для этой цели одним из типов, определенных в FCL, но может оказаться и так, что там пока отсутствует подходящий тип. В таком случае вам потребуется определить собственный тип, производный от класса System.Exception.
Если вы собираетесь создать иерархию исключений, постарайтесь, чтобы она содержала как можно меньше базовых классов. Дело в том, что базовые классы зачастую обрабатывают несколько ошибок по одним правилам, а это может быть опасно. Соответственно, никогда не следует создавать объекты System. Exception и всегда нужно соблюдать максимальную осторожность при генерировании исключений базовых классов'.
ВНИМАНИЕ
В данном случае приходится также иметь дело с ограничениями, связанными с поддержкой версий. Если определить новый тип исключения как производный от существующего, код, перехватывавший исключения старого типа, будет работать и с новым типом. В некоторых сценариях такое поведение требуется, в других — нет. Весь вопрос в том, каким образом код, перехватывающий исключения базового класса, реагирует на тип исключения и производные от него типы. Не ожидавший появления новых типов код может повести себя непредсказуемо и даже стать причиной бреши в системе безопасности. Определяющий новый тип исключения программист не может знать всех мест, в которых окажется перехваченное базовое исключение. Не осведомлен он и о способах его обработки. Поэтому принять однозначно верное решение в подобной ситуации, увы, невозможно.
Во-вторых, следует решить, какое строковое сообщение должно быть передано конструктору исключения. Генерирование исключения должно сопровождаться подробной информацией о том, почему метод не смог решить свою задачу. При обработке перехваченного исключения это сообщение остается невидимым. С другой стороны, если исключение останется необработанным, оно с большой вероятностью будет зарегистрировано в журнале. Необработанное исключение свидетельствует о наличии в приложении дефекта, об искоренении которого должен позаботиться разработчик. Конечные пользователи не имеют доступа к исходному коду и не могут перекомпилировать программу. Соответственно, не видят они и данного сообщения, поэтому туда можно включать всю техническую информацию, необходимую для устранения дефекта.
Более того, так как все разработчики должны понимать английский язык (ведь языки программирования, а также FCL-классы и FCL-методы написаны на английском), не имеет смысла локализовывать текст сообщения. Впрочем, если вы создаете библиотеку классов для разработчиков, говорящих на других языках, ничто не запрещает выполнить локализацию. Именно по этой причине Microsoft локализует сообщения исключений, генерируемых FCL. [13]
Создание классов исключений
К сожалению, процедура создания классов исключений трудоемка и часто сопровождается ошибками. Дело в том, что все типы, производные от типа Exception, должны иметь возможность сериализоваться, а это означает, что они не могут выйти за границы домена приложений и их нельзя записывать в журнал или базу данных. Впрочем, особенности сериализации рассматриваются в главе 24, а пока для простоты я создал обобщенный класс Exception<TExceptionArgs>:
[Serializable]
public sealed class Exception<TExceptionArgs> : Exception, ISerializable where TExceptionArgs : ExceptionArgs {
private const String c_args = "Args"; // Для (де)сериализации private readonly TExceptionArgs m_args;
public TExceptionArgs Args { get { return m_args; } }
public Exception(String message = null, Exception innerException = null)
: this(null, message, innerException) { }
public Exception(TExceptionArgs args, String message = null,
Exception innerException = null): base(message, innerException) { margs = args; }
// Конструктор для десериализации; так как класс запечатан, конструктор // закрыт. Для незапечатанного класса конструктор должен быть защищенным [SecurityPermission(SecurityAction.LinkDemand,
Flags=SecurityPermissionFlag.SerializationFormatter)] private Exception(SerializationInfo info, StreamingContext context)
: base(info, context) { m_args = (TExceptionArgs)info.GetValue( c_args, typeof(TExceptionArgs));
}
// Метод для сериализации; он открыт из-за интерфейса ISerializable [SecurityPermission(SecurityAction.LinkDemand,
Flags=SecurityPermissionFlag.SerializationFormatter)] public override void GetOb]ectData(
Serializationlnfo info, StreamingContext context) { info.AddValue(c_args, margs); base.GetOb]ectData(info, context);
}
public override String Message { get {
String baseMsg = base.Message;
return (m args == null) ? baseMsg : baseMsg + " (" + m_args.Message + "")";
}
public override Boolean Equals(Object obj) {
Exception<TExceptionArgs> other = obj as Exception<TExceptionArgs>; if (obj == null) return false;
return Object. Equals(m_args, other.m_args) && base.Equals(obj);
}
public override Int GetHashCode() { return base.GetHashCodeQ; }
}
Очень прост и базовый класс ExceptionArgs, которым ограничивается класс TExceptionAngs. Вот как он выглядит:
[Serializable]
public abstract class ExceptionArgs {
public virtual String Message { get { return String.Empty; } }
}
Имея эти два класса, я могу легко определять дополнительные классы исключений. Скажем, тип исключения, указывающий на нехватку свободного пространства на диске, может выглядеть так:
[Serializable]
public sealed class DiskFullExceptionArgs : ExceptionArgs {
private readonly String mdiskpath; // закрытое поле, задается
// во время создания
public DiskFullExceptionArgs(String diskpath) { mdiskpath = diskpath; }
// Открытое предназначенное только для чтения свойство,
// которое возвращает поле
public String DiskPath { get { return mdiskpath; } }
// Переопределение свойства Message для включения в него нашего поля public override String Message { get {
return (m_diskpath == null) ? base.Message : "DiskPath=" + m_diskpath;
}
}
}
И если в этот класс не нужно включать дополнительные данные, он будет выглядеть совсем просто:
[Serializable]
public sealed class DiskFullExceptionArgs : ExceptionArgs { }
Теперь можно написать код, генерирующий и перехватывающий такое исключение:
public static void TextExceptionQ { try {
throw new Exception<DiskFullExceptionArgs>(
new DiskFullExceptionArgs(@"C:\"), "The disk is full");
}
catch (Exception<DiskFullExceptionArgs> е) { Console.WriteLine(e.Message);
}
}
ПРИМЕЧАНИЕ
Хотелось бы сделать пару замечаний по поводу класса Exception<TExceptionArgs>. Во-первых, любой определенный с его помощью тип исключения будет производным от System.Exception. Это не проблема для большинства сценариев, более того, даже предпочтительно использовать широкую иерархию типов. Во-вторых, в диалоговом окне, появляющемся в Visual Studio при наличии необработанных исключений, не отображается параметр обобщенного типа Exception<T>, как показано на следующем рисунке.
|
Продуктивность вместо надежности
Я начал заниматься программированием в 1975 году. Написав изрядное количество программ на языке BASIC, я заинтересовался аппаратным обеспечением и перешел на язык ассемблера. Еще через некоторое время я переключился на язык С, дающий доступ к аппаратному обеспечению на более высоком уровне абстракции и облегчающий программирование. Я писал код для операционных систем, для платформ, для библиотек. И всегда старался сделать свой код по возможности компактным и быстрым, потому что для хорошей работы приложения качественным должно быть не только само приложение, но и используемые им операционная система и библиотеки.
Также внимательно я относился к восстановлению после ошибок. Выделяя память (при помощи оператора new в C++ или методов malloc, HeapAlloc, VirtualAlloc и т. п.), я всегда проверял возвращаемое значение, чтобы убедиться, что памяти действительно хватает. При неудовлетворительном результате запроса я программировал обходной путь, гарантируя, что состояние остальной части программы останется незатронутым и что вызывающая сторона получит сообщение об ошибке и сможет принять меры по ее исправлению.
По какой-то необъяснимой причине при написании кода .NET Framework многие программисты не практикуют подобный подход. Вероятность столкнуться с нехваткой памяти существует всегда, но я практически никогда не вижу блок catch с кодом восстановления после исключения OutOfMemoryException. Более того, мне встречались разработчики, утверждавшие, что CLR не позволяет программам перехватывать это исключение. Разумеется, это неправда. На самом деле при выполнении управляемого кода возможны различные ошибки, но я еще не сталкивался с разработчиками, которые писали бы код для восстановления после потенциальных сбоев. В этом разделе мы поговорим как раз о таких сбоях. А также о том, почему считается допустимым игнорировать такие ситуации. Кроме того, я опишу несколько проблем, которые могут возникнуть, если не обращать внимания на эти сбои, и предложу пути решения.
Объектно-ориентированное программирование позволяет добиться от разработчиков высокой продуктивности. Изрядная заслуга тут принадлежит композиционным удобствам, облегчающим написание, чтение и редактирование кода.
Например, рассмотрим строку:
Boolean f = "leff". Substrlng(lj 1).ToUpper().EndsWlth(nE");
Включая в программу такую инструкцию, разработчик делает важное допущение о том, что ее выполнение пройдет без ошибок. Однако ошибки вполне возможны, и нам нужен способ борьбы с ними. Для этого и существуют конструкторы и механизмы обработки исключений, являющиеся альтернативой методам из Win32 и СОМ, возвращающим значение true или false в зависимости от результата своей работы.
Продуктивность разработки достигается не только благодаря композиционное™ кода, но и благодаря некоторым возможностям компиляторов. В частности, компилятор способен неявно:
□ вставлять в вызываемый метод необязательнее параметры;
□ упаковывать экземпляры значимого типа;
□ создавать и инициализировать массивы параметров;
□ связываться с членами динамических переменных и выражений;
□ связываться с методами расширения;
□ связываться с перегруженными операторами и вызывать их;
□ создавать делегаты;
□ автоматически определять тип при вызове обобщенных методов, объявлении локальных переменных и использовании лямбда-выражений;
□ определять и создавать классы замыканий (closure) для лямбда-выражений и итераторов;
□ определять, создавать и инициализировать анонимные типы и их экземпляры;
□ писать код поддержки LINQ (Language Integrated Queries).
Да и CLR делает многое для облегчения жизни программистов. К примеру, CLR умеет неявно:
□ вызывать виртуальные и интерфейсные методы;
□ загружать сборки и JIT-компилируемые методы, которые могут стать источником исключений FileLoadException, BadlmageFormatException, InvalidProgramException, FieldAccessException, MethodAccessException, MissingFieldException, MissingMethodException и VerificationException;
□ пересекать границы домена приложений для доступа к объектам типа, производного от MarshalByRefOb ject, которые могут стать источником исключения AppDomainUnloadedException;
□ сериализовать и десериализовать объекты при пересечении границ домена приложений;
□ заставлять поток генерировать исключение ThreadAbortException при вызове методов Thread .Abort и AppDomain.Unload;
□ вызывать методы Finalize, чтобы сборщик мусора до освобождения памяти объекта выполнил завершающие операции;
□ создавать типы в куче загрузчика при работе с обобщенными типами;
□ вызывать статический конструктор типа, который может стать источником исключения TypelnitializationException;
□ генерировать различные исключения, в том числе OutOfMemoryException, DivideByZeroException, NullReferenceException,RuntimeWrappedException, TargetlnvocationException, OverflowException, NotFiniteNumberException, ArrayTypeMismatchException, DataMisalignedException, IndexOutOfRange- Exception, InvalidCastException,RankException, SecurityException и многие другие.
И, разумеется, .NET Framework поставляется с обширной библиотекой классов, содержащих десятки тысяч типов, каждый из которых поддерживает общую, многократно используемую функциональность. Многие из этих типов предназначены для создания веб-приложений, веб-служб, приложений с расширенным пользовательским интерфейсом, приложений для работы с системой безопасности, для управления изображениями, для распознавания речи — этот список можно продолжать бесконечно. И любая часть кода этих приложений может стать источником ошибки. В следующих версиях могут появиться новые типы исключений, наследующие от уже существующих. И ваши блоки catch начнут перехватывать исключения, о которых раньше и не подозревали.
Все это вместе — объектно-ориентированное программирование, средства компилятора, функциональность CLR и грандиозная библиотека классов — делают
.NET Framework столь привлекательной платформой для разработки программного обеспечения[14]. Но я хочу сказать, что все это является потенциальным источником ошибок, которые мы практически не можем контролировать. Пока программа работает, все хорошо: мы легко пишем код, который также легко читается и редактируется. Но как только случается сбой, оказывается, что понять, где именно произошла ошибка и в чем ее причина, практически невозможно. Вот пример, иллюстрирующий сказанное:
private static Object OneStatement(Stream stream, Char charToFind) { return (charToFind + " + stream.GetTypeQ + String.Empty
+ (stream.Position + 512M))
.Where(c=>c == charToFind) .ToArrayQ;
}
Этот немного неестественный метод содержит всего одну инструкцию на С#, но эта инструкция решает сразу несколько задач. Вот IL-код, сгенерированный компилятором C# для этого метода (места потенциальных сбоев, обусловленные неявно выполняемыми операциями, выделены полужирным шрифтом):
.method private hidebysig static object OneStatement(
class [mscorlib]System.10.Stream stream, char charToFind) cil managed {
.maxstack 4 .locals init (
[0] class Program/oc_____ DisplayClassl V_0,
[1] obJect[] V_l)
IL_0000: newobj instance void Program/oc_________ DisplayClassl:: .ctor( )
IL 0005: stloc.0 IL0006: ldloc.0 IL0007: ldarg.l
IL 0008: stfld char Program/oc________ DisplayClassl::charToFind
IL_000d: ldc.i4.5
IL_000e: newarr [mscorlib]System.Object
IL_0013: stloc.l IL_0014: ldloc.l IL_0015: ldc.i4.0 IL_0016: ldloc.0
IL_0017: ldfld char Program/oc________ DisplayClassl::charToFind
IL_001c: box [mscorlib]System.Char
IL_0021: stelem.ref
IL_0022: ldloc.l
IL_0023: ldc.14.1
IL_0024: ldstr "
IL_0029: stelem.ref IL_002a: ldloc.l IL 002b: ldc.14.2
IL_002c: ldarg.0 IL 002d:
callvirt instance class [mscorllbJSystem.Type [mscorlib]System.Object::GetType() IL_0032: stelem.ref IL_0033: ldloc.l IL_0034: ldc.14.3
IL0035: ldsfld string [mscorlibJSystem.String::Empty
IL_003a: stelem.ref
IL_003b: ldloc.l
IL_003c: ldc.i4.4
IL_003d: ldarg.0
IL_003e: callvirt instance int64 [mscorlibJSystem.10.Stream::get_Position() IL_0043: call valuetype [mscorlib]System.Decimal
[mscorlib]System.Decimal::op_Implicit(int64)
IL0048: ldc.i4 0x200
IL_004d: newobj instance void [mscorlib]System.Decimal::.ctor(int32)
IL0052: call valuetype [mscorlib]System.Decimal [mscorlib]System.Decimal::
op_Addition
(valuetype [mscorlib]System.Decimal, valuetype [mscorlib]System.Decimal)
IL_0057: box [mscorlib]System.Decimal
IL_005c: stelem.ref IL_005d: ldloc.l
IL_005e: call string [mscorlibJSystem.String::Concat(object[])
IL0063: ldloc.0
IL0064: ldftn instance bool Program/Oc__________ DisplayClassl: :<OneStatement>b_____ 0(char)
IL_006a: newobj instance
void [mscorlibJSystem.Func'2<charj bool>::.ctor(object, native int) IL_006f: call class [mscorlib]System.Collections.Generic.IEnumerable' 1<!!0> [System.Core]System.Linq.Enumerable::Where<char>( class [mscorlib]System.Collections.Generic.IEnumerable' 1<!!0>, class [mscorlib]System.Func'2<! !0, bool>)
IL_0074: call !!0[] [System.Core]System.Linq.Enumerable::ToArray<char>
(class [mscorlib]System.Collections.Generic.IEnumerable' 1<!!0>)
IL 0079: ret
}
Как видите, исключение OutOfMemoryException может быть сгенерировано
в процессе создания класса ос__ DisplayClassl (тип генерируется компилятором),
массива Object [ ], делегата Func, а также при упаковке типов chan и Decimal. Внутреннее выделение памяти происходит при вызове методов Concat, Where и ToAnnay. Создание экземпляров типа Decimal может сопровождаться вызовом конструктора этого типа, что может стать причиной исключения TypelnitializationException[15]. Затем неявно вызываются операторные методы op_Implicit и op_Addition типа Decimal, которые могут делать что угодно, в том числе и генерировать исключение OvenflowException.
Интерес также представляет обращение к свойству Position класса Stream. Начнем с того, что это виртуальное свойство, поэтому метод OneStatement не «знает», какой код на самом деле следует выполнять, что может привести к любому исключению. Кроме того, так как класс Stream происходит от класса Marshal- ByRefObject, аргумент stream может ссылаться на объект-представитель, сам являющийся ссылкой на объект другого домена приложений. Но другой домен приложений может оказаться выгруженным, что ведет к появлению исключения AppDomainUnloadedException.
И конечно, все вызываемые методы мне совершенно неподконтрольны, потому что они написаны разработчиками из Microsoft. Зато не исключено, что в будущем специалисты Microsoft поменяют их реализацию, а это приведет к новым типам исключений, о которых я на момент написания метода OneStatement не подозревал. Разве возможно сделать этот метод защищенным от всех возможных ошибок? Кстати, проблемы создает и противоположная ситуация: блок catch способен перехватывать типы исключений, порожденные конкретным типом, поэтому возможно выполнение кода восстановления совсем для другой ошибки.
Теперь, получив информацию о возможных ошибках, вы, скорее всего, сами можете ответить на вопрос, почему считается допустимым написание неустойчивого и ненадежного кода — просто учитывать все возможные ошибки непрактично. Более того, порой это вообще невозможно. Также следует учитывать тот факт, что ошибки возникают относительно редко. И поэтому было решено пожертвовать надежностью кода в угоду продуктивности работы программистов.
Исключения хороши еще и тем, что необработанные исключения приводят к аварийному завершению приложения. И это здорово, так как многие проблемы выявляются еще на стадии тестирования. Информации, которую вы при этом получаете (сообщение об ошибке и трассировка стека), обычно достаточно для внесения нужных исправлений. Разумеется, есть и фирмы, не желающие, чтобы их приложения аварийно завершались в ходе тестирования или использования, поэтому разработчики вставляют код, перехватывающий исключение типа System. Exception, базового для всех типов исключений. Другое дело, что при таком подходе возможны ситуации, когда приложение продолжит работу с испорченным состоянием.
Чуть позже мы рассмотрим класс Account, определяющий метод Transfer для перевода денег с одного счета на другой. Представьте, что при вызове этого метода деньги успешно вычитаются с одного счета, но перед зачислением их на другой счет генерируется исключение. Если вызывающий код перехватывает исключения типа System. Exception и продолжает работу, состояние приложения окажется испорченным: как на счете from, так и на счете to будет меньше денег, чем там должно быть. А так как в данном случае мы говорим о деньгах, порча состояния рассматривается уже не как обычная ошибка, а как проблема системы безопасности приложения. Продолжая работу, приложение выполнит еще ряд переводов с одного счета на другой, а значит, порча состояния в рамках приложения продолжится.
Можно сказать, что метод Тransf ег должен сам перехватывать исключения типа System. Exception и возвращать деньги на счет from. Это действительно сработает для более-менее простого метода Transfer. Но если он производит контрольный отчет изъятых денег или если со счетом одновременно работает несколько потоков, попытка отменить операцию уже не будет иметь успеха, а только приведет к новому исключению. А это значит, что ситуация не улучшится, а только ухудшится.
ПРИМЕЧАНИЕ
Можно возразить, что информация о месте возникновения ошибки важнее сведений о ее содержании. Например, полезнее было бы знать, что перевод денег со счета не произошел, а не то, что метод Transfer не сработал из-за исключения SecurityException или OutOfMemoryException. На самом деле, модель обработки ошибок Win32 действует следующим способом: методы возвращают значение true или false, указывая на результативность своей работы. Это дает вам информацию о том, какой именно метод стал причиной проблемы. Затем, если в программе предусмотрен поиск информации о причинах сбоя, вызывается метод GetLastError. В классе System.Exception присутствует свойство Source, указывающее имя незавершенного метода. Но это свойство принадлежит ктипу String, поэтому вам придется самостоятельно анализировать полученные данные. Ктомуже в ситуации, когда два метода вызывают один и тот же метод, свойство Source не поможет вам понять, где именно произошел сбой. Вместо этого придется анализировать строку, возвращенную свойством StackTrace класса Exception. Из-за всех этих сложностей я пока еще не встречал программиста, написавшего подобный код.
Существуют несколько подходов, способных сгладить проблему испорченного состояния:
□ CLR запрещает аварийно завершать потоки во время выполнения кода блоков catch и finally. Поэтому сделать метод Transfer более устойчивым можно следующим способом:
public static void Transfer^Account from, Account to, Decimal amount) { try { /* здесь ничего не делается */ } finally {
from -= amount;
// Прерывание потока (из-за Thread.Abort/AppDomain.Unload)
// здесь невозможно to += amount;
}
}
Тем не менее я настоятельно не рекомендую помещать весь код внутрь блоков finally! Этот прием можно использовать только для изменения самых чувствительных состояний.
□ Класс System. Diagnostics. Contracts. Contract позволяет применять к методам контракты кода. Именно они позволяют проверять аргументы и другие переменные перед модификацией состояния с использованием этих аргументов/ переменных. В случае соответствия контракту вероятность повреждения состояния минимальна (но не невозможна). Если же проверка не проходит, сразу генерируется исключение. Контракты будут более рассмотрены далее в этой главе.
□ Области ограниченного исполнения (CER) дают возможность избежать имеющихся в CLR неоднозначностей. К примеру, перед входом в блок try можно загрузить все требуемые кодом соответствующих блоков catch и finally сборки. Кроме того, CLR скомпилирует весь код блоков catch и finally, включая вызываемые внутри этих блоков методы. Таким способом можно устранить множество потенциальных исключений (в том числе FileLoadException, BadlmageFormatException, InvalidProgramException, FieldAccessException, MethodAccessException, MissingFieldException и MissingMethodException), которые могут возникнуть при попытке выполнения кода восстановления после ошибок (в блоках catch) или кода очистки (в блоке finally). Также это снизит вероятность появления исключения OutOfMemoryException и некоторых других. Области ограниченного исполнения подробно обсуждаются в этой главе.
□ В зависимости от местоположения состояния можно использовать транзакции, гарантирующие, что в состояние вносится либо весь пакет изменений, либо оно остается неизменным. К примеру, транзакции хорошо подходят для хранения данных в базе. Windows в настоящее время также поддерживает транзакционные операции с реестром и файлами (только для томов NTFS), так что вы можете воспользоваться этими функциями. К сожалению, в настоящее время в .NET Framework данная функциональность не представлена. Для использования этих операций потребуется использовать механизм P/Invoke. Дополнительную информацию по данной теме вы найдете в документации класса System. Transactions.TransactionScope.
□ Можно сделать методы более явными. К примеру, класс Monitor обычно используется для включения/отключения блокировки при синхронизации потока:
public static class SomeType {
private static Object s_myLockObJect = new ObjectQ; public static void SomeMethod () {
Monitor.Enter(s_myLockObJect); // В случае исключения произойдет ли
// блокировка? Если да, то этот режим // будет невозможно отключить!
try {
// Безопасная в отношении потоков операция
}
finally {
Monitor.Exit(s_myLockObJect);
}
}
И ...
}
Из-за описанных проблем не стоит использовать этот вариант перегрузки метода Enter класса Monitor. Л учик; переписать код следующим образом:
public static class SomeType {
private static Object smyLockObject = new ObjectQ;
public static void SomeMethod () {
Boolean lockTaken = false; // Предполагаем, что блокировки нет try {
// Это работает вне зависимости от наличия исключения!
Monitor.Enter(s_myLockObject, ref lockTaken);
// Потокобезопасная операция
}
finally {
// Если режим блокировки включен, отключаем его if (lockTaken) Monitor.Exit(smyLockObject);
}
}
И ...
}
Хотя этот код стал более прозрачным, в случае блокировки в рамках синхронизации потоков лучше вообще не прибегать к обработке исключений. Причины этого рассматриваются в главе 30.
Если обнаружится, что состояние было повреждено настолько, что уже не подлежит восстановлению, его надлежит удалить, чтобы не создавать новых проблем. Затем перезапустите приложение, чтобы состояние инициализировалось нормально. Если повезет, повреждение состояния не произойдет снова. Так как управляемое состояние не может выходить за границы домена приложений, для устранения поврежденных состояний достаточно выгрузить домен приложения, воспользовавшись методом Unload класса AppDomain (детали см. в главе 22).
Если вы считаете, что состояние повреждено настолько, что остается только завершить весь процесс, используйте статический метод Fail Fast класса Environment:
public static void FailFast(String message);
public static void FailFast(String message, Exception exception);
Этот метод завершает процесс без выполнения активных блоков try/finally и без вызовов метода Finalize. Ведь выполнение дополнительного кода в поврежденном состоянии может ухудшить положение дел. Тем не менее метод FailFast дает возможность выполнить очистку объектам, производным от класса CriticalFinalizerObject, с которыми мы познакомимся в главе 21. Обычно это нормально, так как эти объекты стремятся просто закрыть исходные ресурсы. К тому же состояние Windows, скорее всего, останется в порядке даже при повреждении состояния CLR или вашего приложения. Метод FailFast записывает сообщение в журнал событий Windows, после чего включает это сообщение в отчет об ошибках. Затем он создает дамп памяти вашего приложения и завершает его работу.
ВНИМАНИЕ
По большей части FCL-код не гарантирует сохранения нормального состояния после неожиданного исключения. При обнаружении исключения, прошедшего через FCL-код, продолжение работы с FCL-объектами повышает вероятность их непредсказуемого поведения.
В этом разделе я попытался познакомить вас с возможными проблемами, связанными с механизмом обработки исключений в CLR. Большинство приложений не стоит использовать при повреждении их состояния, так как это ведет к появлению некорректных данных и даже дефектов в системе безопасности. Если вы пишете приложение, которое не может аварийно завершать свою работу (например, операционную систему или ядро базы данных), не стоит использовать управляемый код. И так как система Microsoft Exchange Server написана в основном средствами управляемого кода, для хранения электронной почты она задействует собственную базу данных. Эта база называется Extensible Storage Engine, она поставляется вместе с Windows и обычно располагается по адресу C:\Windows\System32\EseNT.dll. При желании вы можете воспользоваться этой базой для своих приложений.
Управляемый код хорошо подходит для приложений, которые могут перенести прекращение работы при возможном повреждении состояния. В эту категорию попадает множество приложений. Написать собственную устойчиво работающую библиотеку классов или приложение крайне сложно, именно поэтому в большинстве случаев разработчики предпочитают обходиться управляемым кодом, повышая продуктивность своей работы.
Приемы работы с исключениями
Понимать механизм обработки исключений важно, но не менее важно уметь правильно пользоваться исключениями. Слишком часто я встречал библиотеки, которые перехватывали все исключения без разбора и оставляли разработчика приложения в неведении о возникшем сбое. В этом разделе я предлагаю рекомендации по использованию исключений, которые должен знать каждый разработчик.
ВНИМАНИЕ
Если вы — разработчик библиотек классов и занимаетесь созданием типов, которые будут использовать другие разработчики, отнеситесь к этим правилам очень серьезно. На вас лежит огромная ответственность за разработку интерфейса, применимого к широкому спектру приложений. Помните: вы не знаете всех тонкостей кода, который вызываете через делегаты, а также через виртуальные или интерфейсные методы. Вы также не знаете, какой код будет вызывать вашу библиотеку. Невозможно предвидеть все ситуации, в которых может применяться ваш тип, поэтому не принимайте никаких политических решений. Ваш код не должен решать, что есть ошибка, а что нет — оставьте это решение вызывающему коду.
Внимательно следите за состоянием и старайтесь избежать его повреждения. Проверяйте аргументы, передаваемые вашим методам, с помощью контрактов (см. далее в этой главе). Старайтесь не изменять состояние без необходимости, а если изменяете — будьте готовы к сбоям и последующему восстановлению состояния. Если вы будете соблюдать рекомендации, изложенные в этой главе, у разработчиков приложений не будет особых проблем с использованием типов вашей библиотеки классов.
Если вы — разработчик приложений, определяйте любую политику, какую сочтете нужной. Придерживаясь правил разработки, вы сможете быстрее выявлять и исправлять ошибки в своем коде, что повыситустойчивость ваших приложений. Однако вы вольны отходить от этих правил, если после тщательного обдумывания сочтете это необходимым. Политику приложения (например, более агрессивный перехват исключений кодом приложения) определяете именно вы.
Активно используйте блоки finally
По-моему, блоки finally — прекрасное средство! Они позволяют определять код, который будет гарантированно исполнен независимо от вида сгенерированного потоком исключения. Блоки finally нужны, чтобы выполнить очистку после любой успешно начатой операции, прежде чем вернуть управление или продолжить исполнение кода, расположенного после них. Блоки finally также часто используются для явного уничтожения любых объектов с целью предотвращения утечки ресурсов. В следующем примере в этом блоке размещен весь код, выполняющий очистку (закрывающий файл):
using System; using System.10;
public sealed class SomeType {
private void SomeMethodQ {
// Открытие файла
FileStream fs = new FileStream(@"C:\Data.bin ", FileMode.Open); try {
// Вывод частного от деления 100 на первый байт файла Console.WriteLine(100 / fs.ReadByteQ);
}
finally {
// В блоке finally размещается код очистки, гарантирующий // закрытие файла независимо от того, возникло исключение // (например, если первый байт файла равен 0) или нет fs.Close();
}
}
}
Гарантированное исполнение кода очистки при любых обстоятельствах настолько важно, что большинство языков поддерживает соответствующие программные конструкции. Например, в C# при использовании инструкций lock, using и foneach блоки try/finally создаются автоматически. Компилятор строит эти блоки и при переопределении деструктора класса (метод Finalize). При работе с упомянутыми конструкциями написанный вами код помещается в блок try, а код очистки — в блок finally. А именно:
□ если вы используете инструкцию lock, то внутри блока finally снимается блокировка;
□ если вы используете инструкцию using, то внутри блока finally для объекта вызывается метод Dispose;
□ если вы используете инструкцию foneach, то внутри блока finally для объекта IEnumerator вызывается метод Dispose;
□ если вы определяете деструктор, то внутри блока finally вызывается метод Finalize базового класса.
Например, в следующем коде на C# используются возможности инструкции using. Хотя этот фрагмент короче предыдущего, при обработке исходного текста этого и предыдущего примеров компилятор генерирует идентичный код:
using System; using System.10;
internal sealed class SomeType {
private void SomeMethod() {
using (FileStream fs =
new FileStream(@"C:\Data.bin", FileMode.Open)) {
// Вывод частного от деления 100 на первый байт файла Console.WriteLine(100 / fs.ReadByteQ);
}
}
}
Подробнее об инструкции using мы поговорим в главе 21, а об инструкции lock — в главе 30.
Не надо перехватывать все исключения
Распространенная ошибка — слишком частое и неуместное использование блоков catch. Перехватывая исключение, вы тем самым заявляете, что ожидали его, понимаете его причины и знаете, как с ним разобраться. Другими словами, вы определяете политику для приложения. Эта тема подробно раскрыта в разделе «Продуктивность вместо надежности».
Тем не менее слишком часто приходится видеть примерно такой код:
try {
// Попытка выполнить код, который, как считает программист,
// может привести к сбою...
>
catch (Exception) { }
Этот код демонстрирует, что в нем предусмотрены все исключения любых типов и он способен восстанавливаться после любых исключений в любых ситуациях. Разве это возможно? Тип из библиотеки классов ни в коем случае; не должен перехватывать все исключения подряд: ведь он не может знать наверняка, как приложение должно реагировать на исключения. Кроме того, такой тип будет часто вызывать код приложения через делегата, виртуальный метод или интерфейсный метод. Если в одной части приложения возникает исключение, то в другой части, вероятно, есть код, способный перехватить его. Исключение должно пройти через фильтр перехвата и быть передано вверх по стеку вызовов, чтобы код приложения смог обработать его как надо.
Если исключение осталось необработанным, CLR завершает процесс. О необработанных исключениях мы поговорим чуть позже. Большинство из них обнаруживаются на стадии тестирования. Для борьбы с ними следует либо заставить код реагировать на определенное исключение, либо переписать его, устранив условия, ставшие причиной сбоя. Число необработанных исключений в окончательной версии программы, предназначенной для выполнения в производственной среде, должно быть минимальным, а сама программа должна быть исключительно устойчивой.
ПРИМЕЧАНИЕ
В некоторых случаях метод, не способный решить поставленную задачу, обнаруживает, что состояние некоторых объектов испорчено и не поддается восстановлению. Если разрешить приложению продолжить работу, результаты могут оказаться плачевными, в том числе возможно нарушение безопасности. При обнаружении такой ситуации метод должен не генерировать исключение, а немедленно выполнять принудительное завершение процесса вызовом метода FailFastTnna System. Environment.
Кстати, вполне допустимо перехватить исключение System. Exception и выполнить определенный код внутри блока catch при условии, что в конце этого кода исключение будет сгенерировано снова. Перехват и поглощение (без повторного генерирования) исключения System. Exception недопустимо, так как оно приводит к сокрытию факта сбоя и продолжению работы приложения с непредсказуемыми результатами, что означает нарушение безопасности. Предоставляемая компанией Microsoft утилита FxCopCmd.exe позволяет находить блоки catch (Exception), в коде которых отсутствует инструкция throw. Подробнее мы обсудим это далее в этой главе.
Наконец, допускается перехватить исключение, возникшее в одном потоке, и повторно сгенерировать его в другом потоке. Такое поведение поддерживает
модель асинхронного программирования (см. главу 28). Например, если поток из пула потоков выполняет код, который вызывал исключение, CLR перехватывает и игнорирует исключение, позволяя потоку вернуться в пул. Позже один из потоков должен вызвать метод EndXxx, чтобы выяснить результат асинхронной операции. Метод EndXxx сгенерирует такое же исключение, что и поток из пула, выполнявшего заданную работу. В данной ситуации исключение поглощается первым потоком, но повторно генерируется потоком, вызывавшим метод EndXxx, в результате ошибка не оказывается скрытой от приложения.
Корректное восстановление после исключения
Иногда заранее известно, источником какого исключения может стать метод. Поскольку эти исключения ожидаемы заранее, нужен код, обеспечивающий корректное восстановление приложения в такой ситуации и позволяющий ему продолжить работу. Вот пример (на псевдокоде):
public String CalculateSpreadsheetCell(Int32 row, Int32 column) { String result; try {
result = /* Код для расчета значения ячейки электронной таблицы */
}
catch (DivideByZeroException) {
result = "Нельзя отобразить значение: деление на ноль";
}
catch (OverflowException) {
result = "Нельзя отобразить значение: оно слишком большое";
}
![]() |
}
Этот псевдокод рассчитывает содержимое ячейки электронной таблицы и возвращает строку с ее значением вызывающему коду, который показывает его в окне приложения. Однако содержимое ячейки может быть частным от деления значений двух других ячеек. И если ячейка со знаменателем содержит 0, то CLR генерирует исключение DivideByZeroException. Тогда метод перехватывает именно это исключение и возвращает специальную строку, которая выводится пользователю. Аналогично содержимое ячейки может быть произведением двух других ячеек. Если полученное значение не умещается в отведенное число битов, CLR генерирует объект OverflowException, а также специальную строку, которая будет выведена для пользователя.
Перехватывая конкретные исключения, нужно полностью осознавать вызывающие их обстоятельства и знать типы исключений, производные от перехватываемого типа. Не следует перехватывать и обрабатывать тип System.Exception (без повторного генерирования), так как невозможно предвидеть все возможные исключения, которые могут быть сгенерированы внутри блока try (особенно это касается типов OutOfMemoryException и StackOverf lowException).
Отмена незавершенных операций при невосстановимых исключениях
Обычно для выполнения единственной абстрактной операции методу приходится вызывать несколько других методов, одни из которых могут завершаться успешно, а другие — нет. Допустим, происходит сериализация набора объектов в файл. После сериализации 10 объектов генерируется исключение (например, из-за переполнения диска или из-за отсутствия атрибута Serializable у следующего сериализуемого объекта). После этого исключение фильтруется и передается вызывающему методу, но в каком состоянии остается файл? Он оказывается поврежденным, так как в нем находится частично сериализованный граф объектов. Было бы здорово, если бы приложение могло отменить незавершенные операции и вернуть файл в состояние, в котором он был до записи сериализованных объектов. Правильная реализация должна выглядеть примерно так:
public void SerializeObJectGraph(FileStream fs,
IFormatter formatter, Object rootObJ) {
// Сохранение текущей позиции в файле
Int64 beforeSerialization = fs.Position;
try {
// Попытка сериализовать граф объекта и записать его в файл formatter.Serialize(fs, rootObj);
}
catch { // Перехват всех исключений
// При ЛЮБОМ повреждении файл возвращается в нормальное состояние fs.Position = beforeSerialization;
// Обрезаем файл fs.SetLength(fs.Position);
// ПРИМЕЧАНИЕ: предыдущий код не помещен в блок finally,
// так как сброс потока требуется только при сбое сериализации
// Уведомляем вызывающий код о происходящем,
// снова генерируя ТО ЖЕ САМОЕ исключение throw;
}
}
Для корректной отмены незавершенных операций код должен перехватывать все исключения. Да, здесь нужно перехватывать все исключения, так как важен не тип ошибки, а возвращение структур данных в согласованное состояние. Перехватив и обработав исключение, не поглощайте его — вызывающему коду необходимо сообщить о ситуации. Это делается путем повторного генерирования того же исключения. В C# и многих других языках это осуществляется просто: просто укажите ключевое слово throw без продолжения, как в предыдущем фрагменте кода.
Обратите внимание, что в предыдущем примере не указан тип исключения в блоке catch, поскольку здесь требуется перехватывать абсолютно все исключения. К счастью, в C# достаточно опустить тип исключения, и инструкция throw повторно сгенерирует это исключение.
Сокрытие деталей реализации для сохранения контракта
Иногда бывает полезно после перехвата одного исключения сгенерировать исключение другого типа. Это может быть необходимо для сохранения смысла контракта метода. Тип нового исключения должен быть конкретным (не использоваться в качестве базового или любого другого типа исключений). Рассмотрим псевдокод типа PhoneBook, определяющего метод поиска номера телефона по имени:
internal sealed class PhoneBook {
private String m_pathname; // Путь к файлу с телефонами
// Выполнение других методов
public String GetPhoneNumber(String name) {
String phone;
FileStream fs = null; try {
fs = new FileStream(m_pathname, FileMode.Open);
// Чтение переменной fs до обнаружения нужного имени phone = /* номер телефона найден */
}
catch (FileNotFoundException е) {
// Генерирование другого исключения, содержащего имя абонента,
// с заданием исходного исключения в качестве внутреннего throw new NameNotFoundException(name, е);
}
catch (IOException e) {
// Генерирование другого исключения, содержащего имя абонента,
// с заданием исходного исключения в качестве внутреннего throw new NameNotFoundException(name, е);
}
finally {
if (fs != null) fs.Close();
}
return phone;
}
}
Данные телефонного справочника получают из файла (а не из сетевого соединения или базы данных), но пользователю типа PhoneBook это неизвестно, так как это — особенность реализации, которая может измениться в будущем. Поэтому, если почему-либо файл не найден или не может быть прочитан, вызывающий код получит исключение FileNotFoundException или IOException, которое он не ожидает. Иначе говоря, существование файла и возможность его чтения не являются частью неявного контракта метода; тот, кто вызвал этот метод, не может
знать заранее, что эти допущения будут нарушены. Поэтому метод GetPhoneNumber перехватывает эти два типа исключений и генерирует вместо них новое исключение NameNotFoundException.
При использовании этого приема следует перехватывать конкретные исключения, обстоятельства возникновения которых вы хорошо понимаете. Кроме того, вы должны знать, какие типы исключений являются производными от перехватываемого типа.
Повторное генерирование исключения сообщает вызывающему коду о том, что метод не в состоянии решить свою задачу, а тип NameNotFoundException дает ему абстрактное представление о причине сбоя. Важно задать внутреннее исключение нового исключения как имеющее тип FileNotFoundException или IOException, чтобы не потерять его реальную причину, знание которой может быть полезно разработчику типа PhoneBook.
ВНИМАНИЕ
Используя описанный подход, мы обманываем вызывающий код в двух отношениях. Во-первых, мы искажаем информацию о том, что пошло не так. В нашем примере файл не удалось найти, но мы сообщили о невозможности найти имя. Во-вторых, мы передаем неверную информацию о месте сбоя. Если бы исключение FileNotFoundException могло пробиться наверх стека вызовов, его свойство StackTrace говорило бы, что ошибка произошла в конструкторе FileStream. Но когда мы поглощаем это исключение и генерируем новое NameNotFoundException, трассировка стека укажет, что ошибка произошла в блоке catch, то есть на расстоянии нескольких строк кода от места генерирования исключения. Это может серьезно затруднить отладку, поэтому описанный подход следует использовать с осторожностью.
А теперь предположим, что тип PhoneBook был реализован чуть иначе. Пусть он поддерживает открытое свойство PhoneBookPathname, позволяющее пользователю задавать или получать имя и путь к файлу, в котором нужно искать номер телефона. Поскольку пользователь знает, что данные телефонного справочника берутся из файла, я модифицирую метод GetPhoneNumber так, чтобы он не перехватывал никакие исключения, а выпускал их за пределы метода. Заметьте: я меняю не параметры метода GetPhoneNumber, а способ его абстрагированного представления пользователям типа PhoneBook. В результате пользователи будут ожидать, что путь предусмотрен контрактом PhoneBook.
Иногда генерирование нового исключения после перехвата уже имеющегося преследует целью добавление к исключению новых данных или контекста. Однако эта цель достигается намного проще. Достаточно перехватить исключение нужного вам типа, добавить в коллекцию его свойства Data требуемую информацию и сгенерировать его заново:
private static void SomeMethod(String filename) { try {
// Какие-то операции } catch (IOException e) {
// Добавление имени файла к объекту IOException е.Data.Add("Filename", filename);
throw; // повторное генерирование того же исключения
}
}
Хороший практический пример: если конструктор типа генерирует исключение, которое не перехватывается внутри его метода, это исключение перехватывает своими средствами CLR, а вместо него генерирует исключение TypelnitializationException. Это полезно, так как CLR создает внутри методов код неявного вызова конструкторов типа[16]. Если конструктор типа генерирует исключение DivideByZeroException, ваш код может попытаться перехватить его и восстановиться. При этом вы даже не узнаете о том, что был задействован конструктор типа. А так как CLR преобразует исключение DivideByZeroException в TypelnitializationException, вы четко видите, что причиной исключения стали проблемы с конструктором типа, а не с вашим кодом.
Впрочем, и этот подход имеет и свои недостатки. При вызове метода через отражение CLR автоматически перехватывает все генерируемые этим методом исключения и преобразует их тип в TargetlnvocationException. В результате для поиска сведений о причинах исключения требуется перехватить объект TargetlnvocationException и проанализировать его свойство InnerException. Более того, при работе с отражениями, как правило, приходится иметь дело примерно с таким кодом:
private static void Reflection(Object о) {
try {
// Вызов метода DoSomething для этого объекта var mi = o.GetTypeQ .GetMethod("DoSomething");
mi.Invoke(о, null); // Метод DoSomething может сгенерировать исключение
}
catch (System.Reflection.TargetlnvocationException e) {
// CLR преобразует его в TargetlnvocationException
throw e.InnerException; // Повторная генерация исходного исключения
}
}
Впрочем, все не так плохо. Если для вызова члена используются примитивные динамические типы C# (о них мы говорили в главе 5), сгенерированный компилятором код не перехватывает вообще никаких исключений и не генерирует исключение TargetlnvocationException; исходное сгенерированное исключение просто перемещается вверх по стеку. Именно поэтому многие разработчики вместо отражений предпочитают использовать примитивные динамические типы.
Необработанные исключения
Итак, при появлении исключения CLR начинает в стеке вызовов поиск блока catch, тип которого соответствует типу исключения. Если ни один из блоков catch не отвечает типу исключения, возникает необработанное исключение (unhandled exception). Обнаружив в процессе поток с необработанным исключением, CLR немедленно уничтожает этот поток. Необработанное исключение указывает на ситуацию, которую не предвидел программист, и должно считаться признаком серьезной ошибки в приложении. На этом этапе о проблеме следует уведомить к<)мпаник >, где разработано приложение, чтобы авторы могли устранить неполадку и выпустить исправленную версию.
Разработчикам библиотек классов не нужно даже думать о необработанных ис- клк>Ч1‘м иях. () них должны заботиться только разработчики приложений, которым в приложении следует реализовать политику, определяющую порядок обработки необработанных исключений. Microsoft рекомендует разработчикам приложений просто принять политику CLR. предлагаемую по умолчанию. То есть в случае не- ()браб<яаннс>го исключения Windows создает запись в журнале системных событий. Чтобы просмотреть его, откройте приложение Event Viewer и перейдите к узлу Windows Logs-*Application, как показано на рис 20.1.
Рис. 20.1. Журнал Windows Event с информацией о приложении, работа которого была завершена из-за необработанного исключения |
Дополнительную информацию о проблеме можно получить в приложении Windows Reliability Monitor, запускаемом из панели управления Windows. В нижней
| ||||||
| ||||||
|
Для получения еще более подробной информации следует дважды щелкнуть на имени приложения в нижней части журнала Reliability Monitor. Пример полученных таким способом сведений показан на рис. 20.3, а расшифровку полей можно найти в табл. 20.2. Все необработанные исключения, полученные в управляемых приложениях, помещаются в контейнер CLR20r3.
Таблица 20.2. Сигнатуры проблем
|
|
После фиксации информации о некорректном приложении Windows выводит диалоговое окно, позволяющее пользователю отправить информацию об ошибке в Microsoft[17]. Данный механизм информирования об ошибках называется Windows Error Reporting. Дополнительную информацию о его работе вы можете получить на сайте Windows Quality (http://WinQual.Microsoft.com).
При желании компании могут зарегистрироваться в Microsoft и получать информацию об ошибках своих приложений и компонентов. Подписка бесплатна, но только при условии, что сборки удостоверены подписью VeriSign ID (другое название — подпись издателя ПО для Authenticode).
Впрочем, вы вправе разработать собственную систему получения информации о необработанных исключениях, необходимую для устранения недостатков программы. При инициализации приложения можно проинформировать CLR, что есть
метод, который нужно вызывать каждый раз, когда в каком-либо потоке возникает необработанное исключение.
К сожалению, в разных моделях приложений Microsoft используются разные способы получения доступа к информации необработанных исключений. Можно использовать следующие члены FCL (подробнее см. документацию):
□ Для многих приложений — событие UnhandledException класса System.Арр- Domain. Приложения Windows Store и Microsoft Silverlight не могут обращаться к этому событию.
□ Для приложений Windows Store — события UnhandledException класса Windows. UI .Xaml.Application.
□ Для приложений Windows Forms — виртуальный метод OnThreadException класса System. Windows. Forms. NativeWindow, одноименный виртуальный метод класса System .Windows. Forms .Application и событие ThreadException класса System.Windows.Forms.Application.
□ Для приложений Windows Presentation Foundation (WPF) — событие Di spate he rUn hand led Except ion класса System. Windows. Application, а также события UnhandledException и UnhandledExceptionFilter класса System. Windows.Threading.Dispatcher.
□ Для приложений Silverlight — событие UnhandledException класса System. Windows.Application.
□ Для приложений ASP.NET Web Form — событие Error класса System.Web. UI.TemplateControl. Класс TemplateControl — базовый для System.Web. Ul.Page и System.Web.Ul.UserControl. Кроме того, можно задействовать событие Error класса System.Web.HTTPApplication.
□ Для приложений Windows Communication Foundation — свойство ErrorHandlers класса System.ServiceModel.Dispatcher.ChannelDispatcher.
В завершение темы хотелось бы сказать несколько слов о необработанных исключениях, которые могут произойти в распределенных приложениях, таких как веб-сайты и веб-службы. В идеальном мире серверное приложение, в котором случилось необработанное исключение, зарегистрирует сведения об исключении в журнале, уведомит клиента о невозможности выполнения запрошенной операции и завершит свою работу. Но мы живем в реальном мире, в котором может оказаться невозможным отправить уведомление клиенту. На некоторых серверах, поддерживающих данные состояния (таких как, например, Microsoft SQF Server), непрактично останавливать сервер и запускать его заново.
В серверном приложении информацию о необработанном исключении нельзя возвращать клиенту, так как ему от этих сведений мало пользы, особенно если клиент создан другой компанией. Более того, сервер должен предоставлять клиентам как можно меньше информации о себе самом, так как это снижает вероятность успешной хакерской атаки.
ПРИМЕЧАНИЕ
В CLR некоторые исключения, генерируемые машинным кодом, рассматриваются как исключения поврежденного состояния (Corrupted State Exceptions, CSE). Дело в том, что обычно они являются следствием проблем с CLR или с машинным кодом, неподконтрольных разработчику. По умолчанию CLR не позволяет управляемому коду перехватывать такие исключения и блок finally не выполняется. Вот список CSE-исключений в Win32.
EXCEPTION_ACCESS_VIOLATION | EXCE PTION_STACK_OVE RF LOW |
EXCEPTION_ILLEGAL_INSTRUCTION | EXCEPTION_IN_PAGE_ERROR |
EXCEPTION_INVALID_DISPOSITION | EXCEPTION_NONCONTINUABLE_EXCEPTION |
EXCEPTION_PRIV_INSTRUCTION | STATUS_UNWIND_CONSOLIDATE |
|
Отдельные управляемые методы могут перегружать методы, предлагаемые по умолчанию, и перехватывать эти исключения, применяя к методу атрибут System. Runtime. ExceptionServices. Eland leProcessCorruptedStateExceptions- Attribute. Кроме того, к методу должен быть применен атрибут System.Security. SecurityCriticalAttribute. Можно перегрузить методы, предлагаемые по умолчанию для всего процесса, присвоив элементу legacyCorruptedStateExceptionPolicy в конфигурационном XML-файле приложения значение true. CLR преобразует большинство этих исключений в объект System.Runtime.InteropServices.SEHException. Только исключение EXCEPTIONACCESSVIOLATION преобразуется в объект System. AccessViolationException, а исключение EXCEPTION_STACK_OVERFLOW— в объект System. StackOverf lowException.
ПРИМЕЧАНИЕ
Перед вызовом метода можно воспользоваться методом EnsureSufficientExecutionStack класса RuntimeHelper/yin проверки количества свободного места в стеке для вызова «среднего» метода (который не имеет четкого определения). Если места в стеке недостаточно, метод генерирует исключение InsufficientExecutionStackException, которое вы можете перехватить. Метод EnsureSufficientExecutionStack не принимает аргументов и возвращает значение типа void. Обычно он применяется с рекурсивными методами.
Отладка исключений
В отладчике из Microsoft Visual Studio есть специальная поддержка исключений: выберите команду Exceptions в меню Debug — появится диалоговое окно, показанное на рис. 20.4.
Рис. 20.4. Диалоговое окно Exceptions с различными исключениями |
Здесь показаны типы исключений, поддерживаемые Visual Studio. Раскрыв ветвь Common Language Runtime Exceptions, как показано на рис. 20.5, вы увидите пространства имен, поддерживаемые отладчиком из Visual Studio.
Рис. 20.5. CLR-исключения, упорядоченные по пространствам имен, в диалоговом окне Exceptions в Visual Studio |
Раскрыв пространство имен, как показано на рис. 20.6, вы увидите все определенные в нем типы, производные от System. Exception.
Если для какого-либо исключения установлен флажок Thrown, при генерации этого исключения отладчик остановится. В момент останова среда CLR еще не приступила к поиску подходящего блока catch. Это может быть полезно для отладки кода, ответственного за перехват и обработку соответствующего исключения. Также это может пригодиться в ситуации, когда вы подозреваете, что компонент или библиотека поглощает или повторно генерирует исключение, и не знаете, где поставить точку останова, чтобы застать компонент «на месте преступления».
Если для исключения флажок Thrown не установлен, отладчик остановится, только если после появления соответствующего исключения оно останется необ- рабе )танным. Это наибе >лее популярный вариант, так как обработанное исключение (>значает, что приложение предвидит возникновение подобных исключений и знает, как с ними справляться.
Рис. 20.6. Диалоговое окно Exceptions в Visual Studio с CLR-исключениями, определенными в пространстве имен System |
Вы можете определять собственные типы исключений и добавлять их в окно, щелкнув на кнопке Add. При этом появляется диалоговое окно, показанное на рис. 20.7.
Рис. 20.7. Передача Visual Studio сведений о собственном типе исключений |
В этом окне сначала выбирают тип Common Language Runtime Exceptions, а затем вводят полное имя собственного типа исключений. Вводимый тип не обязательно должен быть потомком System. Exception, так как типы, не совместимые с CLS, поддерживаются в полном объеме. Если у вас два или больше типов с одинаковыми именами, но в разных сборках, различить эти типы невозможно. К счастью, такое случается редко.
Если в вашей сборке определены несколько типов исключений, следует добавлять их по очереди. Я бы хотел, чтобы в следующей версии это диалоговое окно позволяло находить сборку и автоматически импортировать из нее в отладчик Visual Studio все типы, производные от Exception. А возможность дополнительной идентификации каждого типа по имени сборки решила бы проблему одноименных типов из разных сборок.
Скорость обработки исключений
В сообществе программистов вопросы быстродействия, связанные с обработкой исключений, обсуждаются очень часто и активно. Некоторые пользователи считают эту процедуру настолько медленной, что отказываются к ней прибегать. Но я утверждаю, что для объектно-ориентированной платформы обработка исключений обязательна. Собственно, чем ее можно заменить? Неужели вы предпочтете, чтобы методы возвращали значение true или false, чтобы сообщать об успехе или неудаче своей работы? Или воспользуетесь кодом ошибок типа enum? В этом случае; вы столкнетесь с худшими сторонами обоих решений. CLR и код библиотек классов будут генерировать исключения, а ваш код начнет возвращать код ошибок. В итоге вам все равно придется вернуться к необходимости обработки исключений.
Трудно сравнивать быстродействие механизма обработки исключений и более привычных средств уведомления об исключениях (возвращения значения HRESULT, специальных кодов и т. п.). Если вы напишете код, который сам будет проверять значение, возвращаемое каждым вызванным методом, фильтровать и передавать его коду, вызвавшему метод, то быстродействие приложения серьезно снизится. Даже если оставить быстродействие в стороне, объем дополнительного кодирования и потенциальная возможность ошибок окажутся неподъемными. В такой обстановке обработка исключений выглядит намного лучшей альтернативой.
Неуправляемым компиляторам C++ приходится генерировать код, отслеживающий успешное создание объектов. Компилятор также должен генерировать код, который при перехвате исключения вызывает деструктор для каждого из успешно созданных объектов. Конечно, здорово, что компилятор принимает эту рутину на себя, однако он генерирует в приложении слишком много кода для ведения внутренней «бухгалтерии» объектов, что негативно влияет как на объем кода, так и на время исполнения.
В то же время управляемым компиляторам намного легче вести учет объектов, поскольку память для управляемых объектов выделяется из управляемой кучи, за которой следит уборщик мусора. Если объект был успешно создан, а затем возникло исключение, уборщик мусора, в конечном счете, освободит память, занятую объектом. Компилятору не приходится генерировать код для внутреннего учета успешно созданных объектов и последующего вызова деструктора. Это значит, что в сравнении с неуправляемым кодом на C++ компилятор генерирует меньше кода, меньше кода обрабатывается и во время выполнения, в результате быстродействие приложения растет.
За прошедшие годы мне приходилось пользоваться обработкой исключений на многих языках, в различных ОС и в системах с разными архитектурами процессора. В каждом случае обработка исключений была реализована по-своему, со своими достоинствами и недостатками. В некоторых случаях конструкции, обрабатывающие исключения, компилируются прямо в метод, а в других данные, связанные с обработкой исключений, хранятся в связанной с методом таблице, к которой обращаются только при возникновении исключений. Одни компиляторы не способны
выполнять подстановку кода методов, содержащих обработчики исключений, другие не регистрируют переменные, если в методе есть обработчик исключений.
Суть в том, что нельзя оценить величину дополнительных издержек, которые влечет за собой обрабк яка исключений в мри. к >жении. В управляем! >м мире сделать такую оценку еще труднее, так как к< >д сб< >рки может раб< >тать на любой платфс >рме, поддерживающей .NET Framework. Так, код, сгенерированный JIT-компилятором для обработки исклк>чений на Mai цине х86, будет сильш > < яличаться от кода, сгенерированного JIT-комш шятором в системах с пр< л teccc >р< >м IА64 или JIT-компилятором из .NET Compact Framework.
Мне все же удалось протестировать некоторые мои программы с разными JIT-компиляторами производства Microsoft, предназначенными для внутреннего использования. Я неожиданно обнаружил огромную разницу в быстродействии. Отсюда следует, что нужно тестировать свой код на всех платформах, на которых предполагается его применять, и вносить соответствующие изменения. И в этом случае я бы не беспокоился о снижении быстродействия из-за обработки исключений. Как я уже отмечал, польза от обработки исключений намного перевешивает это снижение.
Если вам интересно, насколько обработка исключений снижает производительность вашего кода, используйте встроенный монитор производительности Windows. Показанный на рис. 20.8 снимок экрана демонстрирует счетчики, связанные с обработкой исключений, которые устанавливаются при установке.КЕТ Framework.
Рис. 20.8. Счетчики исключений .NET CLR в окне монитора производительности |
Время от времени попадается какой-нибудь часто вызываемый метод, который активно генерирует исключения. В такой ситуации снижение производительности из-за обработки слишком частых исключений оказывается очень значительным. В частности, в Microsoft слышали от нескольких клиентов жалобы, что при вызове метода Parse класса Int32 и передаче некорректных данных, введенных конечными пользователями, возникал сбой. Так как метод Parse вызывался часто, генерация и перехват исключений серьезно снижали общую производительность приложения.
Для решения заявленной клиентами проблемы и в соответствии с принципами, описанными в этой главе, специалисты Microsoft добавили в класс Int32 метод ТryParse, имеющий две перегруженные версии:
public static Boolean TryParse(String s, out Int32 result); public static Boolean TryParse(String s, NumberStyles styles,
IFormatProvider, provider, out Int32 result);
Как видите, все эти методы возвращают значение типа Boolean, указывающее, содержит ли переданная строка символы, которые можно преобразовать в Int32. Эти методы также возвращают выходной параметр result. Если методы возвращают значение true, параметр result содержит результат преобразования строки в 32-разрядное целое. В противном случае этот параметр оказывается равен 0, но это значение вряд ли может использоваться в коде.
Я бы хотел абсолютно четко прояснить одну вещь: возврат методом ТгуХхх значения false указывает на один и только один тип сбоя. Для других сбоев метод может генерировать исключения. Например, метод TryParse класса Int32 в случае передачи неверного параметра генерирует исключение ArgumentException. И конечно же, он может сгенерировать исключение OutOfMemoryException, если при вызове ТryParse происходит ошибка выделения памяти.
Также хотелось бы подчеркнуть, что объектно-ориентированное программирование повышает производительность труда программиста, и не последним фактором является запрет на передачу кодов ошибок в членах типов. Иначе говоря, конструкторы, методы, свойства и пр. создаются с расчетом на то, что в их работе сбоев не будет. И при условии правильности определения в большинстве случаев при использовании типов сбоев не будет, а значит, не будет снижения производительности, обусловленного исключениями.
Типы и их члены следует определять так, чтобы свести к минимуму вероятность их сбоев в стандартных сценариях их использования. Если вы позже услышите от своих клиентов, что из-за выдачи множества исключений производительность неудовлетворительна, тогда и только тогда имеет смысл подумать о добавлении в тип методов ТгуХхх. Иначе говоря, сначала надо создать оптимальную объектную модель, а затем, если пользователи окажутся недовольными, добавить в тип несколько методов ТгуХхх, которые облегчат им жизнь. Тем же, кто не испытывает проблем с производительностью, лучше продолжить работать с исходной версией типа, потому что она имеет более совершенную объектную модель.
Области ограниченного выполнения
Во многих приложениях не нужны высокая надежность и способность к восстановлению после любых сбоев. Это относится, прежде всего, к клиентским приложениям — например, Notepad.exe и Calc.exe. Многие из нас сталкивались с ситуацией, когда приложения из пакета Microsoft Office — WinWord.exe, Excel.exe и Outlook, exe — просто завершают свою работу из-за необработанных исключений. Также многие серверные приложения (например, веб-серверы) не имеют долгосрочного состояния и в случае необработанных исключений автоматически перезагружаются. Конечно, для некоторых серверов (например, SQL Server) потеря данных в подобных случаях намного более критична.
В CLR информация о состоянии хранится в классе AppDomain (подробно он рассмотрен в главе 22). Его выгрузка сопровождается выгрузкой всего состояния. Соответственно, если поток в домене приложений сталкивается с необработанным исключением, можно выгрузить домен (удалив все состояния), не завершая всего процесса[18].
Областью ограниченного выполнения (Constrained Execution Region, CER) называется фрагмент кода, который должен быть устойчивым к сбоям. Так как домены приложений допускают выгрузку с уничтожением своего состояния, области ограниченного выполнения обычно служат для управления состоянием, общим для нескольких доменов или процессов. Особенно они полезны, если вы собираетесь работать с состоянием, в котором возможны неожиданные исключения. Такие исключения иногда называют асинхронными (asynchronous exceptions). Например, для вызова метода среда CLR должна загрузить сборку, создать тип объекта в куче загрузчика домена приложений, вызвать статический конструктор типа, скомпилировать IL-код в машинный код и т. п. Сбой может произойти в ходе любой из этих операций, и CLR оповестит об этом при помощи исключения.
Если любой такой сбой произойдет в блоке catch или finally, код восстановления или очистки будет выполнен не полностью. Рассмотрим пример кода, в котором возможен сбой:
private static void DemolQ {
try {
Console.WriteLine("In try");
}
finally {
// Неявный вызов статического конструктора Typel
Typel.M();
}
}
private sealed class Typel { static TypelQ {
// В случае исключения M не вызывается Console.Writel_ine("Typel"s static ctor called");
}
public static void M() { }
}
Вот результат работы этого кода:
In try
Typel's static ctor called
Нам нужно, чтобы код в блоке try выполнялся только при условии выполнения кода в связанных с ним блоках catch и finally. Для этого код следует переписать так:
private static void Demo2() {
// Подготавливаем код в блоке finally
RuntimeHelpers.PrepareConstrainedRegions(); // Пространство имен
// System.Runtime.CompilerServices
try {
Console.WriteLine("In try");
}
finally {
// Неявный вызов статического конструктора Туре2 Туре2.М();
}
public class Туре2 {
static Туре2() {
Console.WriteLine("Type2's static ctor called");
}
// Используем атрибут, определенный в пространстве имен // System.Runtime.ConstrainedExecution
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)] public static void M() { }
}
После запуска этой версии кода получаем:
Type2's static ctor called In try
Метод PrepareConstrainedRegions играет особую роль. Обнаружив его перед блоком try, JIT-компилятор немедленно начинает компилировать соответствующие блоки catch и finally. JIT-компилятор загружает любые сборки, создает любые типы, вызывает любые статические конструкторы и компилирует любые методы. Если хотя бы одна из этих операций дает сбой, исключение возникает до входа потока в блок try.
В процессе подготовки методов JIT-компилятор просматривает весь граф вызовов. Однако обрабатывает он только методы, к которым был применен атрибут ReliabilityContractAttribute со значениями параметра Consistency равными WillNotCorruptState или Consistency .MayCorruptlnstance, так как для методов, которые могут повредить домен приложений или состояние процесса, CLR не дает никаких гарантий. Нужно гарантировать, что внутри защищаемого методом PrepareConstrainedRegions блока catch или finally будут вызываться только методы с настроенным, как показано в предыдущем фрагменте кода, атрибутом ReliabillityContractAttribute.
Вот как выглядит этот атрибут:
public sealed class ReliabilityContractAttribute : Attribute { public ReliabilityContractAttribute(
Consistency consistencyGuarantee, Cer cer); public Cer Cer { get; }
public Consistency ConsistencyGuarantee { get; }
}
Он дает разработчику возможность указать степень надежности отдельного метода[19]. Типы Сег и Consistency относятся к перечислениям и определяются следующим образом: enum Consistency {
MayCorгuptProcess, MayCorruptAppDomain,
MayCorruptlnstance, WillNotCorruptState
}
enum Cer { None, MayFail, Success }
Если ваш метод гарантированно не может повредить состояние, используйте значение Consistency .WillNotCorruptState. В противном случае выберите одно из трех других значений в зависимости от степени его надежности. Для метода с гарантированно успешным завершением используйте значение Cer.Success. В противном случае выбирайте параметр Cer .MayFail. Любой метод, для которого не определен атрибут ReliabilityContractAttribute, можно считать помеченным таким вот образом:
[ReliabilityContract(Consistency.MayCorruptProcess, Cer.None)]
Значение Cer.None указывает, что никаких CER-гарантий в данном случае не дается. Другими словами, метод может завершиться неудачно и даже не сообщить об этом. Помните, что большинство этих параметров дают методу возможность проинформировать вызывающую сторону, чего от него можно ожидать. CLR и JIT- компилятор эти сведения игнорируют.
Чтобы написать надежный метод, делайте его как можно меньше по размеру и ограничивайте сферу его действия. Убедитесь, что там не выделяется память под
объекты (например, не выполняется упаковка), не вызывайте внутри ни виртуальных, ни интерфейсных методов, не пользуйтесь делегатами или отражениями, так как в этом случае JIT-компилятор не сможет определить, какой именно метод вызывается на самом деле. Можно даже вручную подготовить все методы при помощи одного из следующих методов класса RuntimeHelpers:
public static void PrepareMethod(RuntimeMethodHandle method) public static void PrepareMethod(RuntimeMethodHandle method,
RuntimeTypeHandle[] instantiation) public static void PrepareDelegate(Delegate d);
public static void PrepareContractedDelegate(Delegate d);
Имейте в виду, что ни компилятор, ни CLR не проверяют гарантии, которые вы даете, снабжая свой метод атрибутом ReliabiltyContractAttribute. Если вы что-то перепутали, состояние вполне может оказаться поврежденным.
ПРИМЕЧАНИЕ
Даже хорошо подготовленный метод может стать источником исключения StackOverflowException. Если среда CLR не выполняется в размещенном (hosted) режиме, исключение StackOverflowException приводит к немедленному завершению процесса путем внутреннего вызова метода Environment.FailFast. Если же среда работает в размещенном режиме, метод PreparedConstrainedRegions проверяет, осталось ли в стеке хотя бы 48 Кбайт свободного места. При ограниченном месте в стеке исключение StackOverflowException генерируется до начала блока try.
Не следует забывать и про метод ExecuteCodeWithGuaranteedCleanup класса RuntimeHelper, который предоставляет еще одну возможность выполнения кода с гарантированной очисткой:
public static void ExecuteCodeWithGuaranteedCleanup(
TryCode code, CleanupCode backoutCode, Object userData);
При вызове этого метода вы передаете тело блоков try и finally в качестве методов обратного вызова, прототипы которых соответствуют этим двум делегатам:
public delegate void TryCode(Object userData);
public delegate void CleanupCode(Object userData, Boolean exceptionThrown);
Упомяну еще один способ гарантированного выполнения кода. Это класс CriticalFinalizerObject, который подробно рассмотрен в следующей главе.
Контракты кода
Контракты кода (code contracts) — это механизм декларативного документирования решений, принятых в ходе проектирования кода, внутри самого кода. Контракты бывают трех видов:
□ предусловия (preconditions) используются для проверки аргументов;
□ постусловия (postconditions) служат для проверки состояния завершения метода вне зависимости от того, нормально он завершился или с исключением;
□ инварианты (object invariants) позволяют удостовериться, что данные объекта находятся в хорошем состоянии на всем протяжении жизни этого объекта.
Контракты облегчают использование кода, его понимание, разработку, тестирование[20], документирование и распознавание ошибок на ранних стадиях. Предусловия, постусловия и инварианты можно представить в виде части сигнатуры методов. При этом вы можете ослабить контракт для новой версии кода, но обратное невозможно — усиление контракта отрицательно скажется на совместимости версий.
В контрактах кода центральное место занимает статический класс System. Diagnostics.Contracts.Contract:
public static class Contract {
// Методы с предусловиями: [Conditional("CONTRACTS_FULL")] public static void Requires(Boolean condition); public static void EndContractBlockQ;
// Предусловия: Always
public static void Requires<TException>(
Boolean condition) where TException : Exception;
// Методы с постусловиями: [Conditional("CONTRACTSFULL")] public static void Ensures(Boolean condition);
public static void EnsuresOnThrow<TException>(Boolean condition) where TException : Exception;
// Специальные методы с постусловиями: Always public static T Result<T>(); public static T 01dValue<T>(T value); public static T ValueAtReturn<T>(out T value);
// Инвариантные методы объекта: [Conditional("CONTRACTS_FULL")] public static void Invariant(Boolean condition);
// Квантификаторные методы: Always public static Boolean Exists<T>(
IEnumerable<T> collection, Predicate<T> predicate); public static Boolean Exists(
Int32 fromlnclusive, Int32 toExclusive, Predicate<Int32> predicate); public static Boolean ForAll<T>(
IEnumerable<T> collection, Predicate<T> predicate); public static Boolean ForAll(
Int32 fromlnclusive, Int32 toExclusive,
Predicate<Int32> predicate);
// Вспомогательные методы:
// [Conditional("CONTRACTS_FULL")] или [Conditional^"DEBUG")] public static void Assert(Boolean condition); public static void Assume(Boolean condition);
// Инфраструктурное событие: обычно в коде это событие не используется public static event EventHandler<ContractFailedEventArgs> ContractFailed;
}
Как упоминалось ранее, многим из этих статических методов назначен атрибут [Conditional("CONTRACTS_FULL")], а некоторым методам класса Helper — еще
и атрибут [Conditional("DEBUG") ]. Это означает, что при отсутствии специального символа, определенного в момент компиляции, компилятор проигнорирует любой написанный вами код вызова этих методов. Пометка Always означает, что компилятор всегда будет создавать код вызова этих методов. Кроме того, методы Requires, Requires<TException>, Ensures, EnsuresOnThrow, Invariant, Assert и Assume дополнительно имеют перегруженные версии (здесь они не показаны), принимающие аргумент типа String. В результате вы можете в явном виде задать сообщение, которое будет выводиться при нарушении контракта.
По умолчанию контракты служат только для документирования, и для их включения нужно вручную указать в свойствах проекта символическое имя CONTRACTS_FULL. Также вам могут потребоваться дополнительные инструменты и панель свойств Visual Studio, которые можно загрузить с сайта http://msdn.microsoft.com/en-us/ devlabs/dd491992.aspx. В пакет Visual Studio эти инструменты пока не входят, так как являясь относительно новыми, они крайне быстро развиваются. И на сайте DevLabs новые версии появляются быстрее, чем в обновлениях Visual Studio. После загрузки и установки новых инструментов появится новая панель свойств (рис. 20.9).
Для включения контрактов кода установите флажок Perform Runtime Contract Checking и в расположенном справа от него раскрывающемся списке выберите вариант Full. Это определяет символическое имя CONTRACTS_FULL при построении проекта и активизирует необходимые инструменты (они кратко описаны далее) после его построения. В результате нарушение контракта во время выполнения программы будут сопровождаться событием ContractFailed класса Contract. Обычно разработчики не регистрируют методов с этим событием, но если вы решите изменить этой традиции, все зарегистрированные вами методы получат объект ContractFailedEventArgs, который выглядит следующим образом:
public sealed class ContractFailedEventArgs : EventArgs {
public ContractFailedEventArgs(ContractFailureKind failureKind,
String message, String condition, Exception originalException);
public ContractFailureKind FailureKind { get; } public String Message { get; }
public String Condition { get; }
public Exception OriginalException { get; } public Boolean Handled { get; } // Верно, если хоть один обработчик
11 вызвал SetHhandled
// Присваивает Handled значение true,
// позволяя игнорировать нарушение
// Верно, если хоть один обработчик // вызвал SetUnwind или threw // Присваивает Unwind значение true,
![]() |
// принудительно генерируя ContractException
CLR и* CS - Microsoft tonal Stud о Р - О к
ЕМ Я» В«МСТ в^Р QEBU6 ти« VH ДОМ 1ЦГ «ьмпктич ОДТО ЦИ
|
0
О N^Aiviafifa Ubly+ir*
D *чек#е*ч*ил*п .Оди**ч
У айн** Р**1*»*! Г Suggail Eroum |
0J Мя Ртолп
О W* t-nrfi
О **• ЬгчышГл I ■ ч ini I h
D •*
Lortecf Ри#ожж?« АютЫу
A£fYjnc*d
[ *Ц (.ДО Ь*? ,Х. 4Ъ ►#« |
[Л4Плш*№«(1|«0еш« I
Е*а&ШкС>жЬа CWto-i [_
Рис. 20.9. Панель Code Contracts для Visual Studio
С этим событием можно зарегистрировать множество методов его обработки. И каждый такой метод может обработать нарушение контракта указанным вами способом. 1 [апример, обработчик может записать сведения о нарушении в журнал, проигнорировать нарушение (вызвав метод SetHandled) или завершить процесс, 11ри вызове любым из методов метода SetHandled нарушение считается обработанным н после результата, возвращенного методом обработки, приложение может работать дальше, если, конечно, обработчик не вызвал метод SetUnwind. Если же такой вызов произошел, т< ни юле завершения всех методов обрабх яки генерируется исключение System. Diagnostics. Contracts. ContractException. Это внутреннее исключение библиотеки MSCorl_ib.dll, значит, вы не сможете написать блок catch для
его перехвата. Если же какой-нибудь из методов обработки становится источником необработанного исключения, сначала вызываются все остальные обработчики, а затем генерируется исключение ContnactException.
Если обработчики событий отсутствуют или ни один из них не вызывает методы SetHandled и SetUnwind и не становится источником необработанного исключения, нарушение контракта сопровождается заданной по умолчанию процедурой. Если среда CLR загружена, приложение оповещается о нарушении контракта. В случаях когда CLR запускает приложение в виде неинтерактивного оконного терминала (сюда относится, к примеру, Windows service application), вызывается метод Environment. FailFast, мгновенно завершающий процесс. Если перед компиляцией был установлен флажок Assert On Contract Failure, появится диалоговое окно, в котором с приложением можно будет связать отладчик. При сброшенном флажке нарушение контракта сопровождается исключением ContractException. Рассмотрим пример класса, использующий контракты кода.
public sealed class Item {/*...*/}
public sealed class ShoppingCart {
private List<Item> mcart = new List<Item>(); private Decimal mtotalCost = 0;
public ShoppingCart() {
}
public void Addltem(ltem item) {
AddItemHelper(m_cart, item, ref m_totalCost);
}
private static void AddItemHelper(
List<Item> m_cart, Item newltem, ref Decimal totalCost) {
// Предусловия:
Contract.Requires(newltem != null);
Contract.Requires(Contract.ForAll(m_cart, s => s != newltem));
// Постусловия:
Contract.Ensures(Contract.Exists(m_cart, s => s == newltem));
Contract.Ensures(totalCost >= Contract.OldValue(totalCost));
Contract.EnsuresOnThrow<IOException>(
totalCost == Contract.OldValue(totalCost));
// Какие-то операции (способные сгенерировать IOException) m_cart.Add(newltem); totalCost += 1.00M;
}
// Инвариант
[ContractInvariantMethod] private void ObJectInvariant() {
Contract.Invariant(m_totalCost >= в);
}
}
В методе AddltemHelper определяется набор контрактов кода. Предусловие указывает, что параметр newltem должен отличаться от null, а добавляемый в список элемент не может дублировать уже имеющиеся. Постусловие гласит, что новый элемент должен присутствовать в списке, а общая цена покупок после этой операции должна увеличиться. В постусловии также сказано, что если метод AddltemHelper по какой-то причине станет источником исключения IOException, параметр totalCost должен сохранить значение, которое он имел перед вызовом метода. Закрытый метод Objectlnvariant гарантирует, что поле m_totalCost объекта не будет содержать отрицательного значения.
ВНИМАНИЕ
Все члены, на которые присутствуют ссылки в предусловиях, постусловиях и инвариантах, должны быть свободны от сторонних эффектов. Такое требование связано с тем, что во время тестирования ни в коем случае не должно меняться состояние объекта. Кроме того, все методы, на которые есть ссылка в предусловии, должны иметь уровень доступа хотя бы не меньший, чем у метода, определяющего само предусловие. В противном случае перед вызовом метода не будет возможности проверить соответствие условиям. Это ограничение не касается членов, на которые есть ссылки в постусловиях и инвариантах. Уровень доступа к ним может быть любым; главное, чтобы код компилировался. Причина снятия ограничения состоит в том, что проверки в постусловии и инварианте не влияют на корректность вызова метода.
ВНИМАНИЕ
Что касается наследования, производный тип не может перегружать и менять предусловия виртуальных членов, определенных в базовом типе. Аналогично, тип реализации члена интерфейса не может менять предусловия, определенные этим членом. Если для члена отсутствует определенный в явном виде контракт, значит, существует неявный контракт, который логично представить в таком виде:
Contract.Requires(true);
И так как при переходе к новой версии ужесточить контракт не получится (или придется пожертвовать совместимостью версий), при вводе новых виртуальных, абстрактных или интерфейсных членов следует очень аккуратно выбирать предусловия. Для постусловий и инвариантов вы можете добавлять и убирать контракты пожеланию, главное, чтобы условия, поставленные в виртуальном/абстрактном/интерфейсном члене, можно было логически соединить с условиями в перегруженном члене.
Итак, теперь вы знаете, как определяются контракты. Пришло время поговорить о том, как они функционируют во время работы программы. Объявлять предусловия и постусловия следует в верхней части методов, чтобы их легко можно было найти. Предусловия проверяются при вызове метода. При этом хотелось бы, чтобы постусловия проверялись только после завершения метода. Для этого созданную компилятором C# сборку следует обработать инструментом Code Contract Rewriter (файл CCRewrite.exe находится по адресу C:\Program Files (x86)\Microsoft\Contracts\ Bin) для получения ее модифицированной версии. После установки флажка Perform Runtime Contract Checking Visual Studio начнет вызывать эту утилиту автоматически еще на стадии создания проекта. Утилита анализирует IL-код всех ваших методов и переписывает его таким образом, чтобы постусловия выполнялись только после завершения методов. Для методов, имеющих несколько точек выхода, утилита CCRewrite. ехе редактирует IL-код, заставляя проверять условие перед завершением метода.
Утилита CCRewrite.exe ищет в типе методы, помеченные атрибутом [Contract- InvariantMethod ]. Имя такого метода может быть любым, но обычно его называют Objectlnvariant и добавляют модификатор private (какя и сделал). Этот метод не имеет аргументов и возвращает void. Обнаружив его, CCRewrite.exe вставляет IL-код вызова метода Objectlnvariant после всех открытых экземплярных методов. В результате состояние объекта проверяется после возвращения значения каждым из методов, гарантируя, что ни один из них не нарушил условий контракта. Метод Finalize и метод Dispose класса IDisposable утилитой CCRewrite.exe не редактируются, потому что состояние объекта перед его уничтожением или отправкой в корзину не имеет никакого значения. Следует также заметить, что один тип может определять несколько методов с атрибутом [ContractlnvariantMethod]; это полезно при работе с частичными типами. Утилита CCRewrite.exe перепишет IL-код таким образом, что все эти методы будут вызываться (в неопределенном порядке) в конце каждого открытого метода.
Методы Assert и Assume не похожи на остальные. Во-первых, они не являются
частью сигнатуры метода и их нельзя поместить в начало. Во время выполнения они действуют идентично: проверяют переданное им условие и в случае его несоблюдения генерируют исключение. Впрочем, есть еще и такой инструмент, как Code Contract Checker (CCCheck.exe), анализирующий производимый компилятором C# IL-код в попытке статически удостовериться в отсутствии нарушений контракта. Эта утилита пытается удостовериться, что все условия, переданные методу Assert, выполнены. Если сделать это не получается, происходит переход к методу Assume.
Рассмотрим пример. Допустим, имеется следующее определение типа:
internal sealed class SomeType {
private static String sname = "Jeffrey";
public static void ShowFirstLetter() {
Console.WriteLine(s_name[0]); // внимание: требования
// не подтверждены: index < this.Length
}
}
При построении этого кода с установленным флажком Perform Static Contract Checking утилита CCCheck.exe выводит предупреждение, приведенное в комментарии. Это сообщение уведомляет о том, что запрос первой буквы элемента s_name
может закончиться неудачей и стать источником исключения, так как неизвестно, всегда ли s_name ссылается на строку, состоящую хотя бы из одного символа. Следовательно, в метод ShowFirstLetter нужно добавить утверждение:
public static void ShowFirstLetterQ {
Contract.Assert(s_name.Length >= 1); // внимание: утверждение
// не подтверждено
Console.WriteLine(s_name[0]);
}
К сожалению, анализируя этот код, утилита CCCheck.exe все равно не может проверить, ссылается ли элемент s_name на строку, содержащую хотя бы один символ. В итоге мы снова получаем предупреждение. Иногда утилита не может проверить утверждение из-за своих внутренних ограничений; вероятно, ее будущие версии смогут осуществлять более полный анализ.
Чтобы обойти недостатки этой утилиты, перейдем от метода Assent к методу Assume. Зная наверняка, что никакой код не внесет изменений в элемент s_name, мы можем отредактировать метод ShowFirstLetter следующим образом:
public static void ShowFirstLetterQ {
Contract.Assume(s_name.Length >= 1); // Предостережений нет!
Console.WriteLine(s_name[0]);
}
В этой версии кода утилита CCCheck.exe верит нам на слово и заключает, что элемент s_name всегда ссылается на строку, содержащую хотя бы один символ. В результате метод ShowFirstLetter проходит статическую проверку контракта кода без предостерегающих сообщений.
Осталось рассмотреть инструмент Code Contract Reference Assembly Generator (CCRefGen.exe). Утилита CCRewrite.exe ускоряет поиск ошибок, но произведенный в процессе проверки контракта код увеличивает размер вашей сборки и отрицательно сказывается на производительности. Исправить этот недостаток можно при помощи утилиты CCRefGen.exe, создающей отдельную сборку со ссылкой на контракт. В Visual Studio она запускается автоматически, если выбрать в раскрывающемся списке Contract Reference Assembly вариант Build. Сборки с контрактами обычно носят имя ИмяСборки.Contracts.dll (например, MSCorLib.Contracts.dll) и содержат только метаданные и описывающий контракт IL-код. Опознать их можно также по примененному к таблице метаданных с определением сборки атрибуту System.Diagnostics.Contracts.ContractReferenceAssemblyAttribute. Утилиты CCRewrite.exe и CCCheck.exe могут использовать сборки со ссылками на контракты в качестве входных данных для анализа.
Ну и самый последний инструмент Code Contract Document Generator (CCDocGen. exe) добавляет информацию о контракте в XML-файл, создаваемый компилятором C# при установке переключателя /doc: file. Этот XML-файл, дополненный утилитой CCDocGen.exe, после обработки инструментом Sandcastle выдает документацию в стиле MSDN с информацией о контрактах.
Глава 21. Автоматическое управление памятью (уборка мусора)
В этой главе рассказано о создании новых объектов управляемыми приложениями, о том, как управляемая куча распоряжается временем жизни этих объектов и как освобождается занятая ими память. Мы рассмотрим работу уборщика мусора общеязыковой среды CLR и проблемы, связанные с его производительностью. В конце главы речь пойдет о приемах проектирования приложений, эффективно использующих память.
Управляемая куча
Любая программа использует ресурсы — файлы, буферы в памяти, пространство экрана, сетевые подключения, базы данных и т. п. В объектно-ориентированной среде каждый тип идентифицирует некий доступный этой программе ресурс. Чтобы им воспользоваться, должна быть выделена память для представления этого типа. Для доступа к ресурсу вам нужно:
1. Выделить память для типа, представляющего ресурс (обычно это делается при помощи оператора new в С#).
2. Инициализировать выделенную память, установив начальное состояние ресурса и сделав его пригодным к использованию. За установку начального состояния типа отвечает его конструктор.
3. Использовать ресурс, обращаясь к членам его типа (при необходимости операция может повторяться).