Pull to refresh

Comments 17

А может кто-нибудь из шарпистов пояснить, если имеется куча реализаций "быстрой" рефлексии/делегатов, то почему по умолчанию не используется какая-нибудь из них вместо стандартной "медленной", которая похоже, никого не устраивает?

Никого не устраивает — громко сказано.
Пока рефлексия не на основной нагрузке приложения, никто её не трогает.
А вот когда внезапно куча ресурсов уходит на рефлексию, тут и начинаются велосипеды — каждый колхозит своё и под свои нужды.

Надо понимать, что рефлексия умеет очень многое, а «быстрая» реализация часто сделана под определенные задачи и остальное просто не умеет, плюс зависит от рантайма.
UFO just landed and posted this here

Потому что:


1) Expressions в их классическом применении обычно не интерпретируют и не выполняют. Вместо этого, их используют для транспиляции кода на С# в код на каком-то другом языке. Например, вы пишите на С# dataTable.Where(x => x.Parent.Id == 42).Select(x => x.Id).ToArray(), а ваш любимый ORM фреймворк превращает это в SQL запрос к базе и возвращает вам уже отфильтрованный результат: SELECT Id FROM dataTable WHERE Parent.Id = 42. Для этого ORM фреймворк «разбирает» выражение которое вы ему передали и «пересобирает» его используя синтаксис SQL; выражение в таком случае не выполняется напрямую.


2) Никакой «быстрой» рефлексии на самом деле не существует: рефлексия это всегда анализ/поиск методов/свойств/итп по имени или какому-либо другому признаку на стадии выполнения кода. По сравнению с вызовом метода по конкретному адресу вычисленному на этапе компиляции, рефлексия, конечно же, медленней. «Быстрой» она становится, когда вы начинаете кешировать результаты анализа/поиска методов, а также, срезать углы при вызове делегатов разрезолвленных рефлексией. С# это все умеет из коробки, библиотеки просто добавляют немного синтаксического сахара. При этом, сложные конструкции обычно выносят за пределы рефлексии в статические generic методы — сводя рефлексию к вычислению правильного типа для вызова такого метода. Вот пример «быстрых» вызовов: https://github.com/autofac/Autofac/blob/e662b6bace37a569eec1e42335336b3fe015855c/src/Autofac/Core/Activators/Reflection/AutowiringPropertyInjector.cs (внизу). Выглядит немного путано, зато никаких дополнительных зависимостей. Вообще, рефлексия обычно нужна в библиотеках типа ORM/DI/Mock, а их авторы стараются не притащить вместе со своей либой что-либо ещё (многие стараются выпускать библиотеки совсем без зависимостей).


3) В некоторых случаях — рефлексию можно заменить на динамику: пишете код как обычно, но вместо типов указываете dynamic — таким образом вы сообщаете языку, что компиляцию надо отложить до времени выполнения и проводить ее только тогда, когда станут известны конкретные типы входных параметров. Dynamic работает на основе «быстрой рефлексии с кешированием» и вы вряд ли сможете его оптимизировать без архитектурных изменений.


4) Если вам понадобилась рефлексия в большом объёме — возможно, вы делаете что-то не так и вместо рефлексии надо использовать какой-то паттерн проектирования или уже готовую библиотеку.

UFO just landed and posted this here
Это по сути и есть то, что решается подмодулями git. Другой вопрос, что кто-то должен заниматься такими законченными фрагментами кода (то есть, весь репозиторий и должен будет состоять из него), и я не знаю таких примеров) Но на внутрикомандном/корпоративном уровне — без проблем

GitHub Gist для каждого такого фрагмента кода (несколько файлов, в пару кликов) создает самый обычный git репозиторий, который так же можно подключить как submodule, в отдельную папку.
К сожалению, в своей практике такого тоже не встречал. Возможно никто так код не распространяет из-за того, что умение обслуживать подмодули — это дополнительный порог для вхождения.

Я думаю, можно немного ускорить библиотеку, если помимо общих геттеров/сеттеров
Func<object, object>
завести специализированные для всех часто используемых типов
Func<object, int>, Func<object, long>, Func<object, string>
и т.д.

Со стороны клиента можно будет пользоваться как
Accessor lengthAccessor = ExpressionDelegates.Accessors.Find(...)
так и
IntAccessor lengthIntAccessor = ExpressionDelegates.Accessors.FindInt(...)

Всё равно, клиент должен знать тип поля. То есть, он не может сделать
lengthAccessor.Set(obj, (object)1L) — это InvalidCastException в runtime, а значит, где-то внутри клиента всё равно есть ветки для разных типов.
Поэтому клиенты, при желании немного пооптимизироваться, могут сгруппировать поля объекта по типам, например, сначала выставить все int, потом все decimal, потом все string… Без лишних cast-ов и боксинга.

Кстати, если это кодогенерация, не получится ли посмотреть в сторону generics?
Accessor<int> lengthAccessor = ExpressionDelegates.Accessors.Find<int>

Так можно охватить сразу все типы значений.

Заворачивать в обобщения пробовал, с ними действительно есть ускорение.
Но подстраивая геттеры/сеттеры без типобезопасности (как в статье) под generic-делегаты


в таком виде

наблюдалось ровно обратное замедление:


|             Вызов геттера: |     Mean |     Error |    StdDev |
|--------------------------- |---------:|----------:|----------:|
|         Accessor из статьи | 5.879 ns | 0.1525 ns | 0.3009 ns |
| Accessor<TTarget, TMember> | 3.245 ns | 0.0156 ns | 0.0138 ns |
|                  IAccessor | 8.521 ns | 0.1514 ns | 0.1416 ns |

Как совместить generic и non-generic варианты, да чтобы не пришлось дважды генерировать похожий код — пока думаю.
Для текущей реализации решил остановиться на non-generic варианте т. к. он все-таки универсальнее.
Например, при анализе дерева выражения, имея только объект Expression<TDelegate>, клиент, конечно, знает тип свойства, но знает он его в run-time, по объекту System.Type и подставить type-argument в дженерик у него нет возможности, кроме как перебирать типы в условных выражениях.


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

1. не понятно, зачем нужен интерфейс IAccessor
2. его тоже можно сделать generic
Скомпилированные делегаты обычно кэшируют, чтобы переиспользовать, но это не спасает в сценариях, когда первый доступ происходит к большому количеству за раз. В таких случаях время run-time компиляции выражений становится значимым и оттягивает запуск приложения или отдельных окон.
Задумался о том, когда же такие сценарии бывают, что нужно компилировать множество Expression-ов при запуске приложения или окна (?). Смог придумать только генерацию маппинга, и сразу вспомнилось, что упомянутый в статье FastExpressionCompile не взлетел в Automapper, после чего у меня сложилось стойкое впечатление, что оптимизаторы Expression работают только на ограниченном наборе кейсов, с которым разбираться не очень-то и хочется. Уж тем более, использовать это в production.

Попробуйте отправить им реквест со своей библиотекой — это будет лучший Proof of concept.

P.S.: В статье не указано никаких ограничений на Expression-ы, но тесты только с простыми вызовами свойств, методов и конструкторов. Хочется чего-то посложнее, например фильтраци объекта по нескольким свойствам и методам, с сочетанием логических операторов.
Задумался о том, когда же такие сценарии бывают, что нужно компилировать множество Expression-ов при запуске приложения или окна (?).

Я по части фронтенда и мобильной разработки и среди библиотек, с которыми сталкивался, вижу следующие сценарии создания множества делегатов из Expression<>:


  • Дата-байндинг в MVVM
    Открывается экран, слой представления которого связывается с большим количеством свойств ViewModel. Например: экран с деталями товара или любой экран с большим списком.
    MvvmCrossинтерпретирует
    MvvmLightинтерпретирует
    ReactivePropertyкомпилирует
    Mugen MVVM Toolkitкомпилирует
    Praeclarum.Bindкомпилирует
    ReactiveUIинтерпретирует
    В последнем ExpressionTrees используются еще и для представления свойств классов в виде потока при помощи WhenAny, чтобы формировать правила поведения при изменении состояния. Одна из базовых вещей, часто задается.
  • Валидация данных
    Открывается экран с множеством полей, над которыми производится сложная валидация. Например: экран профиля пользователя, обратной связи или какой-нибудь заявки.
    FluentValidationкомпилирует.
    ReactiveUI.Validation — интерпретирует
  • DryIoс — создание деревьев из лямбд используется для спецификаций конструкторов и фабричных методов, интерпретируются
    Запускается приложение или экран, в дереве зависимостей которого большое количество компонентов разрешаются фабричными методами.
  • Moq — создание деревьев из лямбд используется для создания и настройки поведения моков. Деревья и компилируются, и интерпретируются.
    Unit-тесты и соответственно моки с деревьями в них запускаются пачками, одновременно. В больших проектах unit-тесты могут исчисляться тысячами и занимать десятки минут, а значит задерживать CI процессы.

В статье не указано никаких ограничений на Expression-ы, но тесты только с простыми вызовами свойств, методов и конструкторов.

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


Ограничения синтаксиса в лямбдах для создания деревьев описаны в отличной обзорной статье от Alexey Golub.
Ограничения библиотеки для создания делегатов указаны в её описании, раздел Possibilities and Limitations.
Не думаю, что они являются частью предмета исследования статьи.


О том, что для генерации интересны только базовые фрагменты кода вроде упоминал :)
Это особенно обидно, когда выражение простое, например содержит только доступ к свойству (в библиотеках для маппинга, сериализации, дата-байндинга), вызову конструктора или метода (для IoC/DI решений).

У интересующих нас фрагментов кода: методов, конструкторов и свойств на стыке run-time и compile-time естественный идентификатор — это сигнатура.

Среди всех узлов синтаксических деревьев сборки нам нужно найти только интересующие нас лямбда-выражения типа System.Linq.Expressions.Expression и отобрать из их узлов-потомков выражения, описывающие доступ к членам классов, создание объектов и вызов методов
А что вы понимаете под «интерпретацией» Expression-ов? Да, есть Compile(true), о которой довольно мало информации (лишь о том, что на каких-то платформах есть интерпретатор, на каких-то — компилятор, это есть и у вас в статье). Но по вашим ссылкам с пометкой «интерпретирует» нет этого вызова.
В больших проектах unit-тесты могут исчисляться тысячами и занимать десятки минут
Компиляция Expression-ов занимает десятки минут? Или какая часть из десятков минут — на компиляцию?)
Генерация делегатов для более сложных выражений сопряжено с проблемами вроде их идентификация для вызывающего кода или захвата переменных.
Так я же не критикую за недостаточный функционал, просто раз уж речь об оптимизации Expression-ов, надо понимать, каких именно, ведь без указания ограничений, может показаться, что это — для всех случаев (кстати, в Possibilities and Limitations ограничения указаны довольно скромные).
О том, что для генерации интересны только базовые фрагменты кода вроде упоминал :)
Про то, что только — речи всё же не шло в этих упоминаниях ;)
А что вы понимаете под «интерпретацией» Expression-ов?

Преобразование узлов дерева выражений в набор вызовов операций. У библиотек выше это функции рефлексии, у Compile(true) под капотом то же. Например выражение получения значения свойства преобразовывают из MemberExpression в PropertyInfo.GetValue.


Компиляция Expression-ов занимает десятки минут? Или какая часть из десятков минут — на компиляцию?)

Десятки минут может занимать прогон unit-тестов, если их в проекте несколько тысяч. В целом, безотносительно к проектам с компиляцией выражений.


Я в таких проектах замеров не делал, но раз уж упомянул Moq, то по нему и прикинем:
Там на данный момент 1603 теста и в них компиляция используется 1007 раз.
Судя по результатам профилирования прогона тестов (Sampling, CPU-Time) на компиляцию уходит 1.1% от общего времени выполнения.
image


image
В моем случае компиляция выражений составила суммарно 725 мс, что соответствует порядку ее значений на моем ПК.
Обычно в CI тесты прогоняются на неслабом железе, так что, пожалуй, для прикинутого мною сценария с Moq проблема задержки из-за компиляции не особо актуальна.

Я такое точно не смогу повторить. Видимо, эта задачка только для продвинутых специалистов.
Sign up to leave a comment.

Articles