суббота, 30 августа 2008 г.

Расширения для System.Enum

Довольно странно, что языковые средства расширяются от версии к версии платформы, однако нововведения как-правило не затрагивают те части FCL, что знакомы нам с .NET 1.0. Речь пойдет о способах скрыть неуклюжие обращения к методам класса System.Enum, и добавить кое-какую функциональность. Определимся со списком целей:
  • метод object Enum.Parse(Type, string): приходится дважды указывать тип перечислителя (один раз аргументом метода, другой для приведения результата). Попытаемся подсахарить;
  • метод bool Enum.IsDefined(Type, object) так же требует указания типа перечислителя. В случае, когда тип перечислителя уже зашит в аргументе это лишнее. Например, когда требуется проверить принадлежность значения перечислителя к набору констант перечислителя без применения конструкции switch. Подсахарить;
  • проверка на наличие установленного флага выглядит слишком громоздко для использования в операторах ветвления:
    if(FileAccess.Read == (fileAccess & FileAccess.Read)) { ... }
    Еще хуже выглядит проверка на наличие сразу нескольких флагов. Определим метод, который с небольшим оверхедом по производительности скрасит конструкцию. (Наверняка, в Nemerle есть соответствующие макросы)
Для реализации задуманного потребуется два класса. Один - generic класс, в котором будут определены методы для работы с перечислителями а так же кое-какие статические поля, обеспечивающие работу этих методов:
public static class EnumExtensions<TEnum>
   where TEnum : struct, IComparable, IConvertible, IFormattable
{
}
Обратите внимание на ограничения параметра типа. Я постарался выбрать ограничения, максимально близкие к типу enum, просто для того, чтобы компилятор и intellisense не позволяли использовать этот класс для других типов. На самом деле ни одно из ограничений не понадобится для реализации требуемой функциональности. Они фиктивны. К сожалению, не удалось исключить создание типов аля EnumExtensions<int>. При создании таких типов можно добиться двух эффектов: исключения TypeInitializationException, либо ограниченной работоспособности. В данном конкретном случае предпочитаю избежать TypeInitializationException.

Второй класс нужен только для объявления extension-методов, т.к. они не могут быть определены в generic-классах:
public static class EnumExtensions
{
}
Начнем с метода Parse. В generic-классе определим следующий метод:
public static TEnum Parse(string value)
{
   return (TEnum)Enum.Parse(typeof(TEnum), value);
}
В классе для extension методов определим метод-обертку для вышеописанного:
public static TEnum Parse<TEnum>(this string value)
   where TEnum : struct, IComparable, IConvertible, IFormattable
{
   return EnumExtensions<TEnum>.Parse(value);
}
Обращаться к методу-расширению можно в следующей форме:
var mode = modeString.Parse<FileMode>();
Для реализации метода IsDefined(TEnum value) потребуется хранение набора констант, полученных с помощью метода Enum.GetValues(Type). Дело в том, что подглядев реализацию метода Enum.IsDefined(Type, object), я понял что меня не устраивает такое количество выполняемого кода для проверки значения на допустимость. Даже цена получения массива значений в Enum.GetValues(Type) слишком высока для обращения к этому методу более одного раза! Добавим в generic-класс следующий код:
private static readonly TEnum[] s_values = GetValues();

private static TEnum[] GetValues()
{
   return (typeof(TEnum).IsEnum)
       ? (TEnum[])Enum.GetValues(typeof(TEnum))
       : new TEnum[]{};
}

public static int IndexOf(TEnum value)
{
   return Array.IndexOf(s_values, value);
}

public static bool IsDefined(TEnum value)
{
   return IndexOf(value) > -1;
}
В порядке объявления: s_values - статическое поле для хранения массива величин типа TEnum. Инициализируется в объявлении. Статический метод GetValues проверяет, является ли тип TEnum перечислителем, и возвращает массив значений перечислителя, либо пустой массив, избегая исключения TypeInitializationException (оно непременно возникнет при возбуждении исключения ArgumentException при обращении к методу Enum.GetValues(Type) во время инициализации типа ExtensionMethods<TEnum>). Методы IndexOf и IsDefined тривиальны.

Отмечу только, что я не гарантирую их работоспособность при использовании типов, отличных от enum, которые смогли пролезть через набор ограничений Type-параметра (примитивные типы int, long,... и некоторые пользовательские типы). Уверен, что при желании читатель сможет самостоятельно реализовать достойное поведение этих методов для типов, отличных от enum. Думаю, что самым достойным здесь будет возбуждение исключения NotSupportedException.

Да, метод GetIndex(TEnum) получился в качестве бонуса, и я не вижу необходимости скрывать его. Может оказаться полезным. Добавим соответствующие extension-методы в класс EnumExtensions (не generic-класс):
public static int GetIndex<TEnum>(this TEnum value)
   where TEnum : struct, IComparable, IConvertible, IFormattable
{
   return EnumExtensions<TEnum>.IndexOf(value);
}

public static bool IsDefined<TEnum>(this TEnum value)
   where TEnum : struct, IComparable, IConvertible, IFormattable
{
   return EnumExtensions<TEnum>.IsDefined(value);
}
Вот код, проверяющий работу метода IsDefined и демонстрирующий обращение к нему через extension-метод:
Assert.IsTrue(FileMode.Open.IsDefined());

Assert.IsFalse(((FileMode)1000).IsDefined());
Получился весьма элегантный способ проверки значений на принадлежность к набору констант. Приступим к реализации метода bool HasFlags(TEnum value, TEnum flags). Не сложно реализовать такой метод с помощью обращения к методу ulong IConvertible.ToUInt64(IFormatProvider), однако производительность такого решения будет не на высоте (как минимум 4 операции boxing-а, 2 обращения к extern методам, 2 unboxing-а). Так же этот метод будет оперировать 64-х разрядными величинами, даже в тех случаях, когда перечислитель основан на более коротких типах.

Недавно пришло в голову, что динамически сгенерированный метод для соответствующего enum-типа может быть вполне приемлемым по производительности решением. Следующий метод определяет динамический метод DynamicMethod и делегирует генерацию кода переданному в качестве аргумента методу.
private static DynamicMethod BuildHasFlagsMethod(Type enumType, Action<ILGenerator> ilGenAction)
{
   var method = new DynamicMethod(
       "HasFlagMethod",
       typeof(bool),
       new Type[] { enumType, enumType },
       typeof(EnumExtensions).Module);

   var il = method.GetILGenerator();
   ilGenAction(il);
   return method;
}
Я не зря параметризовал этот метод параметром Type, в то время как в generic-классе известен тип TEnum. Для оптимизации работы JIT-компилятора и объема генерируемого им машинного кода следует выносить методы, не использующие явно типы generic аргументов, в не generic-классы. В рамках этого поста я буду располагать такие методы в generic-классе, но буду подразумевать, что читатель вынесет их во вспомогательный класс (если, конечно, статья окажется полезной для него, и он решит воспроизвести код).

Следующие два метода генерируют код реализации метода HasFlags. Первый генерирует хорошую реализацию, а второй - реализацию, которая выбрасывает исключение NotSupportedException. Вторая реализация пригодится для перечислителей без атрибута [Flags], либо для типов, не являющихся enum-ами.
static void EmitHasFlags(ILGenerator il)
{
   il.Emit(OpCodes.Ldarg_1);
   il.Emit(OpCodes.Ldarg_0);
   il.Emit(OpCodes.Ldarg_1);
   il.Emit(OpCodes.And);
   il.Emit(OpCodes.Ceq);
   il.Emit(OpCodes.Ret);
}

static void EmitThrowException(ILGenerator il)
{
   var ctor = typeof(NotSupportedException).GetConstructor(new Type[] { });
   il.Emit(OpCodes.Newobj, ctor);
   il.Emit(OpCodes.Throw);
}
Метод EmitHasFlags записывает два аргумента в стек для выполнения операций логического умножения и сравнения результата со втрорым аргументом, выполняет операции умножения и сравнения, затем возвращает результат. Операция логического умножения может быть выполнена только над примитивными типами, потому необходимо гарантировать обращение к этому методу генерации только при корректном generic-аргументе.
private static readonly Func<TEnum, TEnum, bool> s_hasFlagsMethod;

public static bool IsFlags { get; private set; }

static EnumExtensions()
{
   IsFlags = HasFlagsAttribute(typeof(TEnum));

   Action<ILGenerator> ilGenAction;
   if(IsFlags)
       ilGenAction = EmitHasFlags;
   else
       ilGenAction = EmitThrowException;

   var method = BuildHasFlagsMethod(typeof(TEnum), ilGenAction);

   s_hasFlagsMethod = (Func<TEnum, TEnum, bool>)method.CreateDelegate(
       typeof(Func<TEnum, TEnum, bool>));
}

internal static bool HasFlagsAttribute(Type enumType)
{
   return enumType.GetCustomAttributes(typeof(FlagsAttribute), false).Length > 0;
}

public static bool HasFlags(TEnum value, TEnum flags)
{
   return s_hasFlagsMethod(value, flags);
}
В порядке объявления: s_hasFlagsMethod - делегат для сгенерированного метода; IsFlags - свойство, указывающее на наличие атрибута [Flags] у типа TEnum. Это еще один полезный бонус, который можно оставить опубликованным; далее - статический конструктор.

Остановимся на нем подробнее: Первым делом он инициирует свойство IsFlags значением, возвращаенным методом HasFlagsAttribute(Type). Затем выбирается метод для генерации кода. При значении свойства IsFlags, равном true, можно гарантировать, что TEnum - enum тип, т.к. компилятор не даст применить атрибут [Flags] к любому другому типу, кроме enum. Однако, варьируя условие выбора метода генерации IL кода, можно добиться генерации рабочего метода для целых типов TEnum, при необходимости. Я, правда, такой необходимости не вижу. Для целых методов нет нужды обращаться к динамически сгенерированному коду, потому как можно определить методы обычным образом.

Наконец, реализация метода HasFlags(TEnum, TEnum), которая делегирует динамически сгенерированному методу. Объявление соответствующего extension-метода:
public static bool HasFlags<TEnum>(this TEnum value, TEnum flags)
   where TEnum : struct, IComparable, IConvertible, IFormattable
{
   return EnumExtensions<TEnum>.HasFlags(value, flags);
}
Использование этого метода может быть например таким:
if(fileAccess.HasFlags(FileAccess.Read)) { ... }
Предлагаю сравнить этот кусок кода с аналогичным выше. Думаю, что этот информативнее. Однако, не следует использовать этот метод в критичном по производительности коде.

P.S. Код для проверки флагов в типе int может выглядеть так:
public static bool HasFlags(this int value, int flags)
{
   return flags == (value & flags);
}

...
if(myIntValue.HasFlags(0x80)) { ... }
P.P.S Пробовал сгенерировать код с помощью Expression. Вот что вышло:
var valueParam = Expression.Parameter(typeof(TEnum), "value");
var flagsParam = Expression.Parameter(typeof(TEnum), "flags");
Expression body = Expression.Equal(
    flagsParam,
    Expression.And(valueParam, flagsParam));
В последней строке получил исключение: System.InvalidOperationException: The binary operator And is not defined for the types 'System.IO.FileAccess' and 'System.IO.FileAccess'.. Получается, что il.Emit(OpCodes.And); можно выполнить над Enum-ом, а Expression.And - нет. Был искренне удивлен.

3 комментария:

  1. Для того, чтобы заработали Expression нужно написать:
    var valueParam = Expression.Convert(Expression.Constant(value),
    Enum.GetUnderlyingType(typeof(TEnum)));

    для flag соответственно. Тогда нормально отработает методы And и Equal для Expression.

    ОтветитьУдалить