Expression Trees в .NET: інструментарій, структура та сценарії використання
Привіт, я Кирило Поліщук, full-stack .NET-розробник, Team Lead у Sigma Software, тренер у Sigma Software University. Я помітив, що одним із гепів для .NET-розробників є тема Expression Trees. В цій статті я пропоную розібратися, що це таке та чому варто про них знати.
З одного боку, тема доволі проста, адже більшість .NET-розробників так чи інакше використовують цей інструментарій. Якщо тема статті вас зацікавила, то ви, очевидно, вже добре знайомі на практиці з LINQ (Language Integrated Queries), Lambda Expressions та Delegates. Більшість LINQ-провайдерів створюються саме за допомогою Expressions. Водночас мало хто розуміє, як Expression Trees влаштовані всередині та які сценарії використання є для них оптимальними.
По суті, Expression Trees це класи, які можуть представляти .NET-код у вигляді даних із деревоподібною структурою. Кожна нода цього дерева репрезентує частину кодового виразу. Ці ноди можуть бути операторами, операндами, змінними, викликами методів або будь-якими іншими елементами виразу. Та перш ніж перейти безпосередньо до «дерев», згадаймо базовий інструментарій.
Делегати, анонімні методи та лямбди
Delegates. Почнемо з делегатів. Зазвичай їх опановують ще на базових етапах навчання в .NET. Якщо коротко, це такий собі референс на методи. Делегат допомагає зберігати сигнатуру метода і допомагає його описати. У прикладі нижче ми бачимо, що це делегат, що саме він повертає і які параметри приймає.
public delegate void Output (string payload); public static void DisplayData (string output) { Console.WriteLine(output); } static void Main(string [ ] args) { Output show = DisplayData; show("this is the action"); }
Далі в цьому прикладі ми бачимо, що в нас є якийсь метод DisplayData
, який нічого не повертає і приймає всередину string. Далі в методі Main ми присвоюємо цей DisplayData-метод делегату Output, тобто створюємо інстанс цього делегату. І вже потім викликаємо метод DisplayData
, звернувшись до нього через делегат.
У фреймворку .NET є два основних делегати, які використовує переважна більшість розробників. Йдеться, ясна річ, про Action та Func — банальні делегати, які давно прописані за нас. Єдина різниця між ними — це те, що Func повертає результат.
public delegate void Action<in T>(T obj); public delegate TResult Func<out TResult>();
Звичайно, параметрів на вхід і на вихід може бути набагато більше. Довго думавши, як же ж вони це імплементували, я вирішив відкрити source code і побачив отаке:
public delegate void Action<in T1,in T2,in T3,in T4,in T5,in T6,in T7,in T8,in T9,in T10,in T11,in T12,in T13,in T14,in T15,in T16>(T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5, T6 arg6, T7 arg7, T8 arg8, T9 arg9, T10 arg10, T11 arg11, T12 arg12, T13 arg13, T14 arg14, T15 arg15, T16 arg16);
Максимально він приймає 16 параметрів, тобто, у нас є вже заготовані 16 типів, до яких прописані аргументи. Ніякої магії, просто розумні розробники придумали, що отак от буде круто. З Func все абсолютно так само, тільки на один параметр більше — власне, той тип, який повертається.
Anonymous Methods і Lambda. На основі делегатів ми можемо створювати методи без явної «приписки» до певного класу — так звані анонімні методи. Всередині делегат містить посилання на певний клас.
Знову ж таки, багато хто стикався з анонімними методами під час навчання, можливо зустрічав у книжках, але в реальній роботі ми використовуємо їх дуже рідко. Втім, вони лежать в основі однієї крутої штуки, яку ми зараз побачимо.
Ніякої великої магії тут немає. Компілятор зробить те, що ми вже бачили вище, тобто створить екземпляр делегату та класу, в якому буде згенерований метод з кодом, який був створений програмістом.
delegate(string output) { Console.WriteLine(output); } [CompilerGenerated] private sealed class ClosureClass { public int x; public void Lambda(string output) { Console.WriteLine(output); } }
Навіщо нам це потрібно? Річ у тім, що це лежить в основі Lambda. Компілятор візьме нашу лямбду, тобто оцю магічну стрілочку в коді, і створить з цього анонімний метод, з якого власне вже й створюється instance-делегат.
Action<string> show = (output) => Console.WriteLine(output);
LINQ. Нарешті, поговоримо трошки про «лінки», які використовуються для різних дата-сорсів. Є два типи запису «лінків». Перший — це query-синтаксис:
var linqExperts = from p in programmers wherep.Country == "France" select new LINQExpert(p);
Другий — це Method-синтаксис:
var linqExperts = programmers .Where(p => p.Country == "France") .Select(p => new LINQExpert(p));
Вони є еквівалентними, компілятор у будь-якому разі перетворить перший тип запису в другий. Різниця хіба в тому, що за допомогою query-синтаксису можна робити далеко не все, іноді його доводиться міксувати з методами. Microsoft рекомендує використовувати саме query, але реально на проєктах використовуються і той, і інший типи. Головне — одразу домовитися всередині команди, який саме будете використовувати.
Expression Trees
Тепер, власне, поговоримо про Expression Trees. Перш за все, чому вони так називаються?
Наведу банальний приклад із документації Microsoft. Запис x > 12 ми можемо розкласти на ноди. Ідея в тому, що в нас є оператор, є права і ліва сторони, і вони поєднуються в таку от деревоподібну структуру.
Навіщо це робиться? Для того, аби ми могли контролювати ланцюжок викликів, тобто щоб компілятору, а точніше вже безпосередньо фреймворку .NET, дати зрозуміти, що ми від нього хочемо. Зокрема, що треба взяти за перший операнд, з чим порівняти, який вихідний результат ми хочемо отримати тощо.
Наступний приклад показує, як маленький запит розкладається в таку от деревоподібну структуру.
Ось це, власне, і є Expression Tree. Ідея в тому, що компілятор будує з певних класів структуру — дерево. Робиться це для того, аби зробити запит до колекції даних.
Варто зазначити, що все це робиться в LINQPad. Я намагався знайти аналоги для macOS, але достатньо якісних не знайшов. Доводиться використовувати Windows-застосунки, але воно того вартує.
В LINQPad є дуже багато прикладів (не тільки про Expression Trees, але й про LINQ загалом), і ідея в тому, що ви можете покроково вивчати побудоване дерево і те як вони працюють. «Лінки» тримають у собі близько 50 методів, які можна використовувати.
LINQPad може бути підключений до бази даних, він може бути підключений до вашої власної бібліотеки, яку ви підтягнете в редактор LINQPad. Для чого? По-перше, ви зможете зрозуміти, як будуються дерева.
Якщо клікнути на якийсь з елементів запису і він буде підсвічений у дереві, це дасть нам можливість зрозуміти послідовність та перформанс і, наприклад, розв’язати проблему якогось циклічного запиту, коли дублюються дані. Це також дозволяє не те що дебажити, а саме гратися з кодом з погляду пошуку оптимального рішення.
Розбираємо приклад Expression Tree
А тепер давайте ще раз поговоримо про приклад із виразом x > 12. Нижче ми бачимо записи, які відтворюють цей вираз у вигляді Expressions. Приклад примітивний і, на перший погляд, записаний аж занадто складно, але дає розуміння, як влаштовані ці дерева.
var x = Expression.Parameter(typeof(int), "x"); var constant = Expression.Constant(12); var equation = Expression.GreaterThan(x, constant); var lmdExp = Expression.Lambda<Func<int, bool>> (equation, false, new List<ParameterExpression>(){ x }); var func = lmdExp.Compile(); var result = func(22); Console.WriteLine(result);
Var x
— у цьому випадку інстанційований тип об’єкта Expression. Варто зазначити, що Expression як такий не можна інстанцювати руками — треба використовувати статичні методи. Це зроблено для того, аби компілятор міг зрозуміти, які Expressions ви використовуєте.
Йдемо далі. Expression.Parameter(typeof(int), "x")
— що це значить? Це значить, що в нас є певний параметр, x — тобто число, яке ми будемо потім порівнювати. Для цього використовуємо Expression.Parameter, інстанціюємо клас з типу Parameter, отже очікуємо якийсь параметр типу x. Далі Constant, константа — число, з яким ми порівнюємо x.
Тепер де ж власне це порівняння: Expression.GreaterThan
. І передаємо сюди (x, constant)
. Тобто власне ставимо задачу перевірити, чи x більше за константу, тобто за число 12.
Працювати це поки ще не буде, бо нам потрібно з цього ще зробити лямбду. Коли ми виконуємо метод лямбда, компілятор створює нам екземпляр делегата, готує для цього всі дані.
Ми кажемо, мовляв, зроби нам, будь ласка, лямбду, типізовану отаким чином: var lmdExp = Expression.Lambda<Func<int, bool>>
. Тобто це має бути якийсь метод з інтерфейсом, який приймає всередину параметр і повертає bool
. Це знову ж таки делегат, і ви можете використовувати будь-які потрібні вам делегати, аби виконувати саме свої задачі, але в 99% відсотків випадків Action та Func має бути достатньо.
Отже, ми сказали, давайте нам лямбду з інтерфейсом <Func<int, bool>>
. Що ж нам для цього треба всередину? З чого ми будемо створювати наше рівняння, яке вже має в собі x і константу. І прокидуємо всередину параметри. Тобто оцей x виконує роль вхідного параметра функції.
Далі в нас іде Compile
, тобто якщо подивитися в debug
, то оця змінна Func
буде власне делегатом, який ми там прописали. Точніше це буде інстанс з делегатом інтерфейсу Func
.
Нижче у нас є:
var result = func(22); Console.WriteLine(result);
Тепер якщо це все запустити та перевірити — має повернути [true]
. А якщо ми натомість поставимо func(10)
, то відповідно має повернути [false]
. Тобто він виконає оту операцію, яка й була задумана й поставлена заздалегідь, тобто перевірити, чи дійсно x > 12.
Виникає резонне питання — а навіщо це все потрібно, якщо можна було просто написати if x > 12
і все. Але цей простий приклад ми використали, аби продемонструвати, що ідеологічно Expression Tree відкриває серйозний пласт взаємодії з інтерфейсами в динамічному програмуванні, тому що оцей Function може бути будь-яким, тобто генерування цієї функції може відбуватися динамічно.
Приклад із більш складним проєктом, який яскравіше ілюструє принцип дії та користь Expression Trees, я виклав на GitHub — можна ознайомитися з ним за оцим лінком. Він демонструє, як збірка Expressions залежить від того, які параметри вводить користувач. Тобто ми можемо змінити поведінку програми залежно від того, чого ми хочемо чи які елементи керування в нас є.
LINQ to IQueryable
Тепер трошки більше поговоримо про те, як ці запити взагалі перетворюються на те, до чого ми звикли. Наприклад, в цьому випадку поговоримо про DbQuery — це складова Entity Framework, а конкретно це Link до SQL.
Коли ви хочете зробити щось самостійно, вам доведеться зіштовхнутися з двома типами — iQueryable і QueryProvider. В чому ж їхній взаємозв’язок і навіщо вони потрібні?
iQueryable буде являти собою тип, до якого ви будете звертатися. Тобто в нашому випадку, наприклад, List чи DB-set, DB-context — вони всі реалізують iQueryable.
iQueryProvider — це тип, який, власне, який виконує трансформування структури класів у тип запиту. Тобто, коли ви звертаєтеся до DB-set з Linq, Linq створює Expression Tree на основі вашої конструкції (метод чи query—синтаксис), передає це дерево в QueryProvider і каже: «Транслюй мені, будь ласка».
За цим посиланням доступний DbQueryProvider.cs, який є вхідною точкою, де Entity Framework починає конвертацію лінків в SQL. Допоміжним класом в цьому випадку є ExpressionVisitor. Він допомагає обходити певні типи та певні ноди — наприклад, binary, чи якісь інші, які ми можемо відмічати як опрацьовані.
Для прикладу подивимось на ці інтерфейси і їхні елементи.
public class CustomQueryable : IQueryable { public Expression Expression public IQueryProvider Provider } public class CustomQueryableProvider : IQueryProvider { public IQueryable CreateQuery(Expression expression) public object? Execute(Expression expression) }
Тут є два елементи, які принципово важливі для імплементації. Інтерфейс IQueryable нам несе IQueryProvider, про який я казав вище, і той Expression, який буде виконуватися. В IQueryProvider у нас є два методи, це CreateQuery та Execute. Всі реалізації, з якими я стикався, у методі CreateQuery звертаються до методу Execute.
Тут проявляється та особливість, про яку у розробників часто запитують на співбесідах, а саме — чим IEnumerable відрізняється від iQueryable. Річ у тім, що iQueryable тут і зараз у нас створює тільки query. І то не факт — залежно від того, який у нас тип data source і як він реалізований.
Сама магія починається, коли ми робимо Execute. У нас є query, яка тримає Expression Tree. Тобто ми розуміємо, що на вході у нас є побудована синтаксична модель того, чого ми хочемо добитися. І вже на основі цієї моделі ми будуємо запит до бази даних, у випадку Linq to Sql, до прикладу.
В принципі, потенціал цього не менший, ніж у власне Expression Tree. Тому що зараз існує багато різних data-сорсів, і якщо ви дійдете до рівня розробки якихось фреймворків, цілком вірогідно, що вам доведеться імплементувати ці штуки.
Варто зазначити, що це доволі нетривіальна задача. Але в гайдах від Microsoft на просторах інтернету є досить непогані мануали про те, на які аспекти варто звертати увагу. Ключовий момент — це повторюваність, наприклад, за DB-контекстом. Все це є в опенсорс-доступі, можна завантажити собі та подивитися, викладено гарно і доступно.
Навіщо це все
Наостанок хотів би відповісти на популярне питання, пов’язане з тим, навіщо .NET-розробнику взагалі розуміти, що таке Expression Trees, як вони використовуються і чому б натомість просто не використовувати методи розширення «лінків». Власне, методи розширення LINQ якраз і компілюються в Expressions.
Дійсно, на перший погляд, код з використанням Expression Trees занадто громіздкий, а сфера його застосування неочевидна. Але коли ми виходимо за межі типізованого коду, який компілюється ‘тут і зараз’, у нас відкривається новий пласт динамічного програмування. Цей пласт несе в собі можливості, які звичайним типізованим стилем зробити важко, довго, та й підтримувати складно. З методами розширення ми не можемо зробити Select з якогось невідомого коду за властивістю об’єкта. А от з Expression Trees ви можете це зробити.
Другий можливий кейс — якщо нам складно маніпулювати за допомогою linq-даних, бо linq-запити не транслюються так, як нам би того хотілося, особливо в базу даних. Тобто якщо в нас є якісь замикання між запитами в linq ми запитуємо одну сутність, потім другу, потім третю, в них може виникнути проблема, що об’єктна структура компілюється і працює дуже добре, але на етапі виконання в нас буде помилка, тому що це неможливо транслювати в SQL-запити, бо об’єкті існують по одному у світі .NET, а SQL-entitities існують по-іншому. І розуміючи що нам треба, розуміючи, як працюють ці Expressions, ми можемо обійтися цими Expressions для того, аби побудувати запит, не змінюючи структуру моделі.
Щоб не робити кастомних запитів до бази даних ми можемо обійтися використанням Expression Tree, сконструювати цей запит і направити його, використовуючи лінки в базу даних.
А ще, розуміючи, як будуються Expression Trees, як їх продебажити, як їх подивитися, в ситуаціях, де в нас є дуже критичним перформанс, можна добитися суттєвого поліпшення, оптимізуючи власне запити. Розуміючи як будується і як це все працює.
Expression Trees — це потужний інструмент не тільки для взаємодії з користувачем, а й для створення та перетворення коду як такого. Це інструмент, який обмежує лише ваша фантазія.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів