Введение

При работе с объектно-ориентированными языками программирования высокого уровня, такими как C#, мы склонны забывать о том, насколько затратным с точки зрения использования ЦП или потребления памяти является наш код. Мы часто не пишем приложения, требующие сверхвысокопроизводительного кода, но время от времени мы можем сталкиваться с ситуациями, когда нам нужно оптимизировать потребление памяти или использование ЦП.

Это, помимо моего первого поста о платформе, является отправной точкой серии, в которой я попытаюсь охватить несколько способов решения определенной ситуации, когда производительность или эффективность могут быть улучшены, проведу бенчмаркинг и напишу некоторые выводы. Эти посты будут представлять собой короткие тексты, в которых каждый сможет быстро найти информацию по теме.

Реализация и контрольные показатели

Итак, давайте погрузимся в наш сценарий. В C# у нас есть несколько способов создания экземпляров наших объектов. Для наших тестов мы будем создавать экземпляры этого класса C#.

public class TestClass
{
    public TestClass(int parameter1, string parameter2)
    {
    }
}

Придерживаясь классики, наиболее распространенным способом создания объектов в C# является использование ключевого слова new. Это первый тест, который мы будем выполнять.

[Benchmark]
public TestClass DirectlyCallingConstructor() => new TestClass(integerValue, stringValue);

В ситуациях, когда нам нужно создать экземпляры универсальных типов, где мы знаем параметры конструктора, мы можем использовать метод Activator.CreateInstance.

[Benchmark]
public void CallActivatorWithType() => Activator.CreateInstance(typeof(TestClass), integerValue, stringValue);

При запуске этих двух тестов мы получаем следующие результаты.

|                  Method |       Mean |     Error |    StdDev |     Median | Ratio | RatioSD |   Gen0 | Allocated | Alloc Ratio |
|------------------------ |-----------:|----------:|----------:|-----------:|------:|--------:|-------:|----------:|------------:|
| DirectlyCallConstructor |   5.664 ns | 0.2286 ns | 0.6597 ns |   5.485 ns |  1.00 |    0.00 | 0.0038 |      24 B |        1.00 |
|   CallActivatorWithType | 237.905 ns | 4.5727 ns | 5.6157 ns | 237.418 ns | 41.55 |    4.71 | 0.0520 |     328 B |       13.67 |

Вызов Activator.CreateInstance — гораздо более сложная операция, чем прямой вызов конструктора класса. Кажется, легко понять, почему это происходит, рефлексия медленная, но мы не всегда можем получить прямой доступ к конструктору класса, но мы хотим создавать экземпляры объектов, используя рефлексию, для создания экземпляров параметров универсального типа.

Есть ли способ создать экземпляры с помощью отражения, который снижает эти накладные расходы на производительность, которые вызывает Activator.CreateInstance? Да, ответ - это предварительно скомпилированные лямбда-выражения. Лямбда-выражения — это анонимные функции, с которыми мы все (разработчики .NET) знакомы, поскольку они широко используются, например, для перебора коллекций с помощью Linq.

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

static Func<TArg1, TArg2, T> CreateCreator<TArg1, TArg2, T>()
{
    var constructor = typeof(T).GetConstructor(new Type[] { typeof(TArg1), typeof(TArg2) });
    var parameter = Expression.Parameter(typeof(TArg1), "p1");
    var parameter2 = Expression.Parameter(typeof(TArg2), "p2");

    var creatorExpression = Expression.Lambda<Func<TArg1, TArg2, T>>(
        Expression.New(constructor, new Expression[] { parameter, parameter2 }), parameter, parameter2);
    return creatorExpression.Compile();
}

Бенчмарк (обратите внимание, что скомпилированные лямбда-выражения кэшируются, а не создаются и компилируются при каждом выполнении теста):

[Benchmark]
public TestClass PrecompiledLamdba() => objectActivator(integerValue, stringValue);

При запуске всех тестов результаты следующие:

|                  Method |       Mean |      Error |     StdDev |     Median | Ratio | RatioSD |   Gen0 | Allocated | Alloc Ratio |
|------------------------ |-----------:|-----------:|-----------:|-----------:|------:|--------:|-------:|----------:|------------:|
| DirectlyCallConstructor |   4.933 ns |  0.3594 ns |  1.0598 ns |   4.344 ns |  1.00 |    0.00 | 0.0038 |      24 B |        1.00 |
|   CallActivatorWithType | 233.254 ns | 10.6189 ns | 30.1240 ns | 225.604 ns | 48.29 |    9.87 | 0.0522 |     328 B |       13.67 |
|        PrecompiledLambda |   4.162 ns |  0.1359 ns |  0.2926 ns |   4.161 ns |  0.79 |    0.18 | 0.0038 |      24 B |        1.00 |

Вызов предварительно скомпилированной кэшированной лямбда-выражения кажется даже более быстрым, чем вызов конструктора, и таким же эффективным с точки зрения выделения памяти, чем вызов конструктора (24B).

Выводы

Хотя такого рода оптимизации могут не оказывать существенного влияния на производительность приложений, они все же могут быть ценными инструментами для повышения эффективности. Но! одна из моих целей в этой серии постов — показать, что очень эффективный код можно написать на C#, и такого рода упражнения покажут нам, как мы можем оптимизировать пути горячего кода, которые могут иметь некоторое влияние. У нас есть много очень хорошо спроектированных инструментов в среде .NET, которые могут помочь нам решить эту задачу.

Что касается реальной темы сообщения, не используйте Activator, в большинстве случаев вы можете переключиться на компиляцию лямбда-выражений и кэшировать эти экземпляры и использовать их вместо вызова Активатор.

Увидимся в следующий раз!