Хитрый способ кэширования Expression Trees
Достаточно часто в .NET-ориентированных блогах я нахожу различные механизмы, построенные с применением C# Expression Trees. Большинство из них предназначены для получения экземпляров MethodInfo
/PropertyInfo
/FieldInfo
по выражениям доступа к методам/свойствам/полям соответственно (ну или просто именам членов классов). Это позволяет, например, при реализации интерфейса INotifyPropertyChanged
уйти от строковых констант (OnChanged(“PropertyName”)
) к лямбда-выражениям (OnChanged(x => x.PropertyName)
), получая при этом проверки уровня компиляции и стойкость к автоматическим рефакторингам.
Проблема лишь в том, как формируются эти деревья во время выполнения. Например, такой код:
class Foo {
public int Value { get; set; }
public Expression<Func<Foo, int>> Bar() {
return x => x.Value;
}
}
Разворачивается компилятором C# вот в такой (это не валидный код на C#, так как компилятор на уровне IL-кода использует специальные инструкции, позволяющие быстро получить MethodInfo
по токену метода get_Value()
, который является get
-аксессором свойства Value
. Примерно такой же код мог бы генерировать C#, если бы поддерживал аналоги typeof()
для методов/свойств/полей):
class Foo {
public int Value { get; set; }
public Expression<Func<Foo, int>> Bar() {
var parameterExpression = Expression.Parameter(typeof(Foo), "x");
return Expression.Lambda<Func<Foo, int>>(
body: Expression.Property(
expression: parameterExpression,
propertyAccessor: (MethodInfo)
MethodBase.GetMethodFromHandle(ldtoken(get_Value()))),
parameters:
new ParameterExpression[] { parameterExpression });
}
}
Да, такая портянка исполняется при каждом формировании, казалось-бы, простого лямбда-выражения x => x.Value
. При этом происходит несколько выделений памяти в куче, а так же множество проверок типов из которых формируется дерево выражения, что мягко говоря, не быстро.
В некоторых случаях этот overhead от формирование деревьев приемлем, но не всегда - например, реализацию интерфейса INotifyPropertyChanged
используют для ViewModel’и в UI-паттерне MVVM (из мира WPF/Silverlight) именно из соображений производительности (вместо использования DependencyProperty
и наследования от DependencyObject
), а применение Expression Trees сводит бенефиты на нет.
Интересно то, что в мире делегатов, компилятор C# при отсутствии замыканий на внешний контекст использует кэширование делегатов, но не существует аналогичного кэширования для деревьев выражений, не смотря на то, что они такие же неизменяемые, как и делегаты .NET (если у вас есть соображения насчёт причин, не позволяющих кэшировать ET так же, как делегаты - милости прошу ко мне в комментарии). О реализации такого кэширования с помощью “грязных хаков” и пойдёт речь далее.
В качестве “подопытного” возмём метод со всеми необходимыми проверками, возвращающий экземпляр PropertyInfo
по выражению доступа к свойству некоторого класса:
using System;
using System.Linq.Expressions;
using System.Reflection;
public static class Property {
public static PropertyInfo Of<T, TProperty>(Expression<Func<T, TProperty>> propertyExpression)
{
if (propertyExpression == null)
throw new ArgumentNullException("propertyExpression");
var memberExpr = propertyExpression.Body as MemberExpression;
if (memberExpr == null)
throw new ArgumentException("MemberExpression expected");
if (memberExpr.Member.MemberType != MemberTypes.Property)
throw new ArgumentException("Property member expected");
return (PropertyInfo) memberExpr.Member;
}
}
Весь трюк заключается в том, что можно вместо дерева с типом Expression<…>
требовать от пользователя обычный делегат с типом Func< Expression<…> >
, то есть простой метод, возвращающий нужное дерево. Вместо x => x.Property
следует передавать () => x => x.Property
. Зачем? Во-первых весь код формирования дерева уезжает в код делегата, уменьшая размер клиентского кода (на уровне IL). Во-вторых делегат можно вызвать лишь один раз, получив дерево выражения и закэшировав его - так как делегат будет закэширован компилятором, то при всех вызовах экземпляр делегата будет одним и тем же и его можно использовать как ключ словаря { делегат => дерево выражения }.
Однако есть способ обойтись и безо всяких словарей. Итак кэшированный аналог выглядит следующим образом:
using System;
using System.Linq.Expressions;
using System.Reflection;
public static class Property
{
public static PropertyInfo FromExpressionCached<T>(Func<Expression<Func<T, object>>> propertyExpression)
{
// если переданный делегат в замыкании хранит наш кэш
var data = propertyExpression.Target as CachedData;
if (data != null) return data.CachedValue;
return FromImpl(propertyExpression); // иначе вычисляем PropertyInfo
}
private static PropertyInfo FromImpl<T>(Func<Expression<Func<T, object>>> propertyExpression)
{
// если у делегата нет замыкания,
// то и у вложенного в него дерева выражения не должно быть
if (propertyExpression.Target != null)
throw new ArgumentException("Delegate should not have any closures.");
if (!propertyExpression.Method.IsStatic)
throw new ArgumentException("Delegate should be static.");
var body = propertyExpression().Body; // вызываем таки делегат
// из-за object у нас может быть тут лишний боксинг
if (body.NodeType == ExpressionType.Convert && body.Type == typeof(object)) {
body = ((UnaryExpression) body).Operand;
}
var memberExpr = body as MemberExpression;
if (memberExpr == null)
throw new ArgumentException("MemberExpression expected");
if (memberExpr.Member.MemberType != MemberTypes.Property)
throw new ArgumentException("Property member expected");
var propInfo = (PropertyInfo) memberExpr.Member;
// раз делегат у нас статический, то он должен быть закэширован
// компилятором в статическом поле типа, в котором он определён
var declaringType = propertyExpression.Method.DeclaringType;
foreach (var fieldInfo in declaringType.GetFields(BindingFlags.Static | BindingFlags.NonPublic)) {
// проходимся по всем статическим полям в поисках делегата
if (ReferenceEquals(fieldInfo.GetValue(null), propertyExpression)) {
// нашёлся - создаём специальный holder для PropertyInfo
var cached = new CachedData { CachedValue = propInfo };
// заменяем делегат в поле на делегат на stub-метод
var stub = new Func<Expression<Func<T, object>>>(cached.Stub<T>);
fieldInfo.SetValue(null, stub);
return propInfo;
}
}
throw new InvalidOperationException("Delegate is not cached.");
}
// аналог closure-класса, хранящий закэшированное значение
private sealed class CachedData {
public PropertyInfo CachedValue { get; set; }
public Expression<Func<T, object>> Stub<T>() {
throw new InvalidOperationException("Should never be called");
}
}
}
То есть мы вызываем переданный делегат единожды и сохраняем вычисленное значение PropertyInfo
прямо в поле кэшированного экземпляра делегата! А это значит, что при следующем вызове из клиентского кода нам передадут не исходный делегат, а нашу заглушку, из замыкания которой очень легко достать закэшированное значение (всего один type test)! Не смотря на массивность кода и рефлексию, работает эта штука по сравнению с Expression Trees просто реактивно:
using System;
using System.Diagnostics;
using System.Threading;
static class Program {
static void Main() {
Thread.CurrentThread.Priority = ThreadPriority.Highest;
const int count = 100000;
var sw = Stopwatch.StartNew();
for (var i = 0; i < count; i++) {
var p = Property.FromExpression((Stopwatch _) => _.Elapsed);
GC.KeepAlive(p);
}
Console.WriteLine("expr: {0}", sw.Elapsed);
sw.Reset();
sw.Start();
for (var i = 0; i < count; i++) {
var p = Property.FromExpressionCached<Stopwatch>(() => _ => _.Elapsed);
GC.KeepAlive(p);
}
Console.WriteLine("hack: {0}", sw.Elapsed);
}
}
На лаптопе с i3 @ 2533Mhz показывает в среднем следующие результаты, почти три порядка разницы:
expr: 00:00:01.0867601
hack: 00:00:00.0014079
p.s. Ради бога, не используйте это решение в production. Весь этот способ - завязка на implementation details компилятора (кэширование делегатов), мутирование чужих статических переменных и прочее безобразие, непонятно как работающее в многопоточной среде. Код приведён исключительно в образовательных целях и лишь показывает, что кэширование Expression Trees имело бы место в C#.