Генерация кода

112

Прием генерации кода часто используется фреймворками, выполняющими сериализацию, такими как механизмы объектно-реляционного отображения (Object/Relational Mappers, ORM) наподобие Entity Framework, динамические прокси-объекты и другими, которые вынуждены работать с объектами неизвестных типов. В .NET Framework поддерживается несколько способов динамической генерации кода, и еще больше способов поддерживается сторонними фреймворками, такими как LLBLGen и T4:

Генерация из исходного кода

Допустим, что вам требуется реализовать фреймворк, выполняющий сериализацию произвольных объектов и сохраняющий результаты в формате XML. Получение непустых, общедоступных полей с использованием Reflection API и их запись - весьма дорогостоящая операция, но ее вполне можно использовать для создания простейших реализаций:

// Упрощенный метод сериализации в XML - не поддерживает 
// коллекции, циклические ссылки и т. д.
public static string XmlSerialize(object obj)
{
    StringBuilder builder = new StringBuilder();
    Type type = obj.GetType();

    builder.AppendFormat("<{0} Type = '{1}'> ", 
        type.Name, type.AssemblyQualifiedName);

    if (type.IsPrimitive || type == typeof(string))
    {
        builder.Append(obj.ToString());
    }
    else
    {
        foreach (FieldInfo field in type.GetFields())
        {
            object value = field.GetValue(obj);
            if (value != null)
            {
        builder.AppendFormat("<{0}>{1}</{0}> ", 
            field.Name, XmlSerialize(value));
            }
        }
    }

    builder.AppendFormat("</{0}>", type.Name);
    return builder.ToString();
}

Вместо этого можно было бы сгенерировать строго типизированный код, выполняющий сериализацию объектов конкретного типа, и вызывать этот код. Ниже представлена реализация, использующая CSharpCodeProvider:

using Microsoft.CSharp;
using System.CodeDom.Compiler;

// ...

private static class XmlSerializationCache<T>
{
    public static Func<T, string> Serializer;
    public static Func<T, string> GenerateSerializer()
    {
        StringBuilder code = new StringBuilder();
        code.AppendLine("using System;");
        code.AppendLine("using System.Text;");
        code.AppendLine("public static class SerializationHelper {");
        code.AppendFormat("public static string XmlSerialize({0} obj) {{", typeof(T).FullName);
        code.AppendLine("StringBuilder result = new StringBuilder();");

        code.AppendFormat("result.Append(\"<{0} Type = '{1}'> \");",
            typeof(T).Name, typeof(T).AssemblyQualifiedName);

        if (typeof(T).IsPrimitive || typeof(T) == typeof(string))
        {
            code.AppendLine("result.AppendLine(obj.ToString());");
        }
        else
        {
            foreach (FieldInfo field in typeof(T).GetFields())
            {
                code.AppendFormat("result.Append(\"<{0}> \");", field.Name);
                code.AppendFormat("result.Append(XmlSerialize(obj.{0}));", field.Name);
                code.AppendFormat("result.Append(\"</{0}> \");", field.Name);
            }
        }

        code.AppendFormat("result.Append(\"</{0}> \");", typeof(T).Name);
        code.AppendLine("return result.ToString();");
        code.AppendLine("}");
        code.AppendLine("}");

        CSharpCodeProvider compiler = new CSharpCodeProvider();

        CompilerParameters parameters = new CompilerParameters();
        parameters.ReferencedAssemblies.Add(typeof(T).Assembly.Location);
        parameters.CompilerOptions = "/optimize + ";

        CompilerResults results = compiler
            .CompileAssemblyFromSource(parameters, code.ToString());

        Type serializationHelper = results
            .CompiledAssembly.GetType("SerializationHelper");

        MethodInfo method = serializationHelper.GetMethod("XmlSerialize");

        Serializer = (Func<T, string>)Delegate
            .CreateDelegate(typeof(Func<T, string>), method);

        return Serializer;
    }
}

public static string XmlSerialize<T>(T obj)
{
    Func<T, string> serializer = XmlSerializationCache<T>.Serializer;
    if (serializer == null)
    {
        serializer = XmlSerializationCache<T>.GenerateSerializer();
    }
    return serializer(obj);
}

Код, использующий Reflection API, выполняется только один раз при генерации строго типизированного кода - результат кешируется в статическом поле и повторно используется всякий раз, когда требуется сериализовать экземпляр данного конкретного типа. Обратите внимание, что пример, представленный выше, не прошел всестороннее тестирование; он лишь доказывает возможность реализации генератора кода. Простой хронометраж показывает, что подход на основе генерации кода более чем в два раза быстрее, чем первоначальный подход, основанный исключительно на применении Reflection API.

Генерация кода с использованием легковесного генератора кода

Рассмотрим еще один пример из области синтаксического анализа сетевого протокола. Допустим, что имеется объемный поток двоичных данных, например, состоящий из сетевых пакетов, и вам нужно выделить из пакетов заголовки и полезные данные. Например, взгляните на следующую структура заголовка пакета (это полностью искусственный пример - заголовки TCP-пакетов имеют совершенно иную структуру):

public struct TcpHeader
{
        public uint Flags;
        public uint Checksum;
        public uint SourceIP;
        public uint DestIP;
        public ushort SourcePort;
        public ushort DestPort;
}

Реализация на C/C++ извлечения такой структуры из потока байтов - тривиальная задача, и для этого не требуется даже копировать данные, если использовать указатели. Фактически, извлечение любых структур из потока байтов, реализуется очень просто:

template <typename T>
const T* get_pointer(const unsigned char* data, int offset) 
{
	return (T*)(data + offset);
}

template <typename T>
const T get_value(const unsigned char* data, int offset) 
{
	return *get_pointer(data, offset);
}

В C# все оказывается гораздо сложнее. Существует множество способов чтения произвольных двоичных данных из потока. Один из них - выделить поля с помощью Reflection API и читать их отдельно от потока байтов:

// Поддерживаются только некоторые простые числа
public static void ReadReflectionBitConverter<T>(byte[] data, int offset, out T value)
{
    object box = default(T);
    int current = offset;

    foreach (FieldInfo field in typeof(T).GetFields())
    {
        if (field.FieldType == typeof(int))
        {
            field.SetValue(box, BitConverter.ToInt32(data, current));
            current += 4;
        }
        else if (field.FieldType == typeof(uint))
        {
            field.SetValue(box, BitConverter.ToUInt32(data, current));
            current += 4;
        }
        else if (field.FieldType == typeof(short))
        {
            field.SetValue(box, BitConverter.ToInt16(data, current));
            current += 2;
        }
        else if (field.FieldType == typeof(ushort))
        {
            field.SetValue(box, BitConverter.ToUInt16(data, current));
            current += 2;
        }

        // ... множество других типов не показано
        value = (T)box;
    }
}

На одном из наших тестовых компьютеров мы выполнили анализ 1 000 000 20-байтных структур TcpHeader и выяснили, что в среднем метод выполняется примерно 170 миллисекунд. Скорость работы выглядит не так уж и плохо, но объем выделяемой при этом памяти операциями упаковки оказался внушительным. Кроме того, при вполне реальной скорости обмена по сети, равной 1 Гбит/сек, приложение будет получать десятки миллионов пакетов в секунду, что потребует значительных затрат вычислительных ресурсов только на чтение структур из входящих данных.

На этом фоне гораздо более удачным выглядит решение, основанное на использовании метода Marshal.PtrToStructure(), предназначенного для преобразования фрагментов неуправляемой памяти в управляемые структуры, этот подход требует закрепления исходных данных в памяти, чтобы получить возможность извлекать их по указателю:

public static void ReadMarshalPtrToStructure<T>(byte[] data, int offset, out T value)
{
    GCHandle gch = GCHandle.Alloc(data, GCHandleType.Pinned);

    try
    {
        IntPtr ptr = gch.AddrOfPinnedObject();
        ptr += offset;
        value = (T)Marshal.PtrToStructure(ptr, typeof(T));
    }
    finally
    {
        gch.Free();
    }
}

Эта версия показывает гораздо более высокую производительность - в среднем 39 миллисекунд на 1 000 000 пакетов. Это существенное улучшение, но Marshal.PtrToStructure() все так же использует динамическую память, потому что возвращает ссылку на объект, и к тому же скорость работы явно недостаточна для обслуживания десятков миллионов пакетов в секунду.

Ранее исследовали использование указателей и небезопасного кода в C#, и похоже, что данный пример - отличная возможность использовать их. В конце концов, версия на C++ настолько проста именно потому, что использует указатели. Следующий код работает намного, намного быстрее, обрабатывая 1 000 000 пакетов за 0.45 миллисекунды - невероятное улучшение!

public static unsafe void ReadPointer(
    byte[] data, int offset, out TcpHeader header)
{
    fixed (byte* pData = &data[offset])
    {
        header = *(TcpHeader*)pData;
    }
}

Почему этот способ оказался таким быстрым? Потому что для копирования данных больше не используются такие ресурсоемкие API, как Marshal.PtrToStructure - копирование выполняет сам JIT-компилятор. Машинный код, полученный в результате компиляции этого метода, может встраиваться (в действительности 64-разрядный JIT-компилятор так и поступает) и использовать для копирования областей памяти 3-4 инструкции (например, инструкцию MOVQ в 32-разрядных системах, копирующую сразу 64 бита). Единственная проблема в том, что получившийся метод ReadPointer не так универсален, как версия на C++.

Первая реакция на это замечание - реализовать универсальную версию:

public static unsafe void ReadPointerGeneric<T>(
   byte[] data, int offset, out T value)
{
    fixed (byte* pData = &data[offset])
    {
        value = *(T*)pData;
    }
}

которая даже не компилируется! В частности, T* - это недопустимая конструкция в C#, потому что нет никакого способа гарантировать, что указатель на T можно будет разыменовать (к тому же, закреплять объекты и получать указатели на них можно, только если они имеют двоично совместимые типы). Поскольку нет никаких обобщенных средств, чтобы выразить наши намерения, похоже, что мы должны будем написать отдельные версии ReadPointer для всех поддерживаемых типов, и в этом нам снова помогут генераторы кода.

Чтобы избежать необходимости писать отдельные копии метода ReadPointer для каждого типа, мы воспользуемся легковесным генератором кода (классом DynamicMethod). Прежде всего исследуем код на языке IL, сгенерированный для метода ReadPointer:

.method public hidebysig static void ReadPointer(
uint8[] data, int32 offset, [out] valuetype TcpHeader& header) cil managed
{
    .maxstack 2
    .locals init ([0] uint8& pinned pData)
    ldarg.0
    ldarg.1
    ldelema uint8
    stloc.0
    ldarg.2
    ldloc.0
    conv.i
    ldobj TcpHeader
    stobj TcpHeader
    ldc.i4.0
    conv.u
    stloc.0
    ret
}

Теперь нам осталось сгенерировать код IL, заменив тип TcpHeader аргументом обобщенного типа. Фактически, благодаря превосходному расширению ReflectionEmitLanguage для утилиты .NET Reflector, которое преобразует методы в вызовы Reflection.Emit, необходимые для генерации кода методов, нам даже не придется писать код вручную - хотя нам придется внести несколько небольших изменений:

using System;
using System.Reflection;
using System.Reflection.Emit;

public delegate void ReadDelegate<T>(byte[] data, int offset, out T value);

public static class DelegateHolder<T>
{
    public static ReadDelegate<T> Value;

    public static ReadDelegate<T> CreateDelegate()
    {
            DynamicMethod dm = new DynamicMethod("Read", null,
                new Type[] { typeof(byte[]), typeof(int), typeof(T).MakeByRefType() },
                Assembly.GetExecutingAssembly().ManifestModule);

            dm.DefineParameter(1, ParameterAttributes.None, "data");
            dm.DefineParameter(2, ParameterAttributes.None, "offset");
            dm.DefineParameter(3, ParameterAttributes.Out, "value");

            ILGenerator generator = dm.GetILGenerator();

            generator.DeclareLocal(
                typeof(byte).MakePointerType(), pinned: true);

            generator.Emit(OpCodes.Ldarg_0);
            generator.Emit(OpCodes.Ldarg_1);
            generator.Emit(OpCodes.Ldelema, typeof(byte));
            generator.Emit(OpCodes.Stloc_0);
            generator.Emit(OpCodes.Ldarg_2);
            generator.Emit(OpCodes.Ldloc_0);
            generator.Emit(OpCodes.Conv_I);
            generator.Emit(OpCodes.Ldobj, typeof(T));
            generator.Emit(OpCodes.Stobj, typeof(T));
            generator.Emit(OpCodes.Ldc_I4_0);
            generator.Emit(OpCodes.Conv_U);
            generator.Emit(OpCodes.Stloc_0);
            generator.Emit(OpCodes.Ret);

            Value = (ReadDelegate<T>)dm.CreateDelegate(typeof(ReadDelegate<T>));

            return Value;
    }
}

public class Program
{
    public static void ReadPointerLCG<T>(byte[] data, int offset, out T value)
    {
            ReadDelegate<T> del = DelegateHolder<T>.Value;
            if (del == null)
            {
                del = DelegateHolder<T>.CreateDelegate();
            }
            del(data, offset, out value);
    }
}

Эта версия обрабатывает 1 000 000 пакетов в среднем за 1.05 миллисекунды - более чем в два раза медленнее, чем ReadPointer, но все еще на два порядка быстрее оригинальной реализации на основе механизма рефлексии - еще одна победа генератора кода. (Потеря производительности в сравнении ReadPointer обусловлена необходимостью получения делегата из статического поля, проверки ссылки на пустое значение и вызов метода с применением делегата.)

Структура TypedReference и два недокументированных ключевых слова в языке C#

В отчаянных ситуациях требуются отчаянные меры, и такими отчаянными мерами являются два недокументированных ключевых слова в языке C#, __makeref и __refvalue (поддерживаемые такими же недокументированными кодами операций на языке IL). Вместе со структурой TypedReference эти ключевые слова используются в некоторых сценариях низкоуровневых взаимодействий с применением методов, имеющих переменное количество аргументов в стиле языка C (что требует применения еще одного недокументированного ключевого слова __arglist).

TypedReference - это небольшая структура с двумя полями типа IntPtr - Type и Value. Поле Value - это указатель на значение, которое может быть ссылочного типа или типа значения, а поле Type - указатель на таблицу методов типа. Создавая экземпляры TypedReference, указывающие на экземпляры типов значений, можно обеспечить интерпретацию содержимого памяти строго типизированным образом, как того требует ситуация, и использовать JIT-компилятор для копирования памяти, как это делается в реализации метода ReadPointer:

// Мы объявляем параметр со спецификатором ref, а не out, 
// потому что нам нужен его адрес, а ключевое слово
// __makeref требует инициализированное значение.
public static unsafe void ReadPointerTypedRef<T>(byte[] data, int offset, ref T value)
{
    // В действительности мы не изменяем 'value' - нам просто 
    // требуется левостороннее значение
    TypedReference tr = __makeref(value);

    fixed (byte* ptr = &data[offset])
    {
        // Первое поле - указатель в структуре TypedReference - это 
        // адрес объекта, поэтому мы записываем в него 
        // указатель на нужный элемент в массиве с данными
        *(IntPtr*)&tr = (IntPtr)ptr;

        // __refvalue копирует указатель из TypedReference в 'value'
        value = __refvalue( tr,T);
    }
}

К сожалению, вся эта «магия» компилятора имеет свою цену. В частности, оператор __makeref компилируется JIT-компилятором в вызов call clr!JIT_GetRefAny, который несет дополнительные накладные расходы в сравнении с полностью встраиваемой версией ReadPointer. Результатом является почти 2-кратная потеря производительности - этот метод обрабатывает 1 000 000 пакетов в среднем за 0.83 миллисекунды. Но, как ни странно, он все еще остается самым быстрым универсальным решением из всех, что были показаны.

Пройди тесты
Лучший чат для C# программистов