Грокаем DLR

в 15:30, , рубрики: .net, .net core, C#, DLR, dynamic programming, IPC, visual basic, Программирование

Предисловие переводчика

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

Многие .NET разработчики слышали про Dynamic Language Runtime (DLR), но почти ничего о нем не знают. Разработчики, пишущие на языках типа C# или Visual Basic, избегают языков с динамической типизацией из-за боязни исторически связанных с ними проблем с масштабируемостью. Также их беспокоит тот факт, что языки типа Python или Ruby не выполняют проверку типов во время компиляции, что может привести к сложным для поиска и исправления ошибкам в рантайме. Это обоснованные опасения, которые могут объяснить, почему DLR не пользуется популярностью среди основной массы .NET разработчиков даже спустя два года после официального релиза [статья довольно старая, но с тех пор ничего не изменилось]. В конце концов, любой .NET Runtime, содержащий в себе слова Dynamic и Language в своем названии, должен предназначаться строго для поддержки таких языков, как Python, правильно?

Притормозите. В то время как DLR действительно была задумана для поддержки «Iron» реализации Python и Ruby в .NET Framework, её архитектура предоставляет гораздо более глубокие абстракции.

Грокаем DLR - 1

Под капотом, DLR предлагает богатый набор интерфейсов для выполнения межпроцессного взаимодействия [Inter-Process Communication (IPC)]. За все эти годы, разработчики видели множество инструментов от Microsoft для взаимодействия между приложениями: DDE, DCOM, ActiveX, .Net Remoting, WCF, OData. Этот список может продолжаться ещё долго. Это практически бесконечный парад акронимов, каждый из которых представляет технологию, обещающую, что в этом году обмениваться данными или вызывать удаленный код будет ещё проще, чем раньше.

Язык языков

Когда я в первый раз слушал выступление Джима Хьюгенина [Jim Hugunin] про DLR, его речь удивила меня. Джим создал реализацию Python под Java Virtual Machine (JVM), известную как Jython. Незадолго до выступления, он присоединился к Microsoft для создания IronPython для .NET. Основываясь на его бэкграунде, я ожидал, что он сфокусируется на языке, но вместо этого Джим почти все время говорил про заумные вещи типа деревьев выражений [expression trees], динамическую диспетчеризацию [dynamic call dispatch] и механизмы кэширования вызовов. Джим описывал набор работающих в рантайме сервисов компиляции, которые позволяли любым двум языкам взаимодействовать друг с другом практически без потерь в производительности.

Во время этой речи я записал термин, который всплыл в моей голове, когда я услышал как Джим пересказывает архитектуру DLR: «язык языков» [the language of languages]. Спустя четыре года, эта кличка по прежнему очень точно характеризует DLR. Однако, приобретя реальный опыт использования, я понял, что DLR это не просто про совместимость языков. Благодаря поддержке динамических типов в C# и Visual Basic, DLR можете выступать в роли шлюза из наших любимых .NET языков к данным и коду в любой удаленной системе, вне зависимости от того, какой тип оборудования или программного обеспечения последняя использует.

Грокаем DLR - 2

Чтобы понять идею, которая лежит в основе DLR, представляющую собой интегрированный в язык IPC механизм, давайте начнем с примера, не имеющего ничего общего с динамическим программированием. Представьте две компьютерные системы: одна называется инициатором, а вторая — целевой системой. Инициатору требуется выполнить функцию foo на целевой системе, передав туда некоторый набор параметров, и получить результаты. После обнаружения целевой системы, инициатор обязан представить всю необходимую для исполнения функции информацию в формате, понятном ей. Как минимум, эта информация будет включать в себя имя функции и передаваемых параметров. После распаковки запроса и валидации параметров, целевая система выполнит функцию foo. После этого она должна упаковать результат, включая любые ошибки, произошедшие во время выполнения, и отправить их обратно инициатору. Наконец, инициатор должен суметь распаковать результаты и оповестить об этом цель. Этот паттерн «запрос-ответ» довольно распространен и на высоком уровне описывает работу практически любого IPC механизма.

DynamicMetaObject

Для понимания того, как DLR реализует представленный паттерн, давайте рассмотрим один из центральных классов DLR: DynamicMetaObject. Начнем с изучения трех из двенадцати ключевых методов этого типа:

  1. BindCreateInstance — создание или активация объекта
  2. BindInvokeMember — вызов инкапсулированного метода
  3. BindInvoke — исполнение объекта (как функции)

Когда вам нужно выполнить метод в удаленной системе, прежде всего необходимо создать инстанс типа. Конечно, не все системы являются объектно-ориентированными, так что термин «инстанс» может быть метафорой. По факту, нужный нам сервис может быть реализован как пул объектов или как синглтон, так что термины «активация» или «соединение» может применяться с тем же правом, как и «инстанциирование».

Другие фреймворки следуют такому же паттерну. Например, COM предоставляет функцию CoCreateInstance для создания объектов. В .NET Remoting вы можете использовать CreateInstance метод из класса System.Activator. DynamicMetaObject из DLR предоставляет BindCreateInstance для похожих целей.

После использования метода BindCreateInstance, созданное нечто может быть типом, предоставляющим несколько методов. Метод метаобъектов BindInvokeMember используется для байндинга операции, которая может вызывать функцию. На картинке выше, строка foo может быть передана как параметр чтобы указать байндеру, что метод с таким именем должен быть вызван. Дополнительно включается информация о количестве аргументов, их именах и специальный флаг, указывающий байндеру, можно ли игнорировать регистр при поиске подходящего именованного элемента. В конце концов, не все языки требовательны к регистру наименований.

Когда нечто, возвращенное из BindCreateInstance — это всего лишь одна функция (или делегат), используется метод BindInvoke. Чтобы прояснить картину, давайте рассмотрим следующий небольшой кусочек динамического кода:

delegate void IntWriter(int n);
    
void Main() {
  dynamic Write =
    new IntWriter(Console.WriteLine);
  Write(5);
}

Этот код не является оптимальным способом вывести число 5 в консоль. Хороший разработчик никогда не будет использовать что-нибудь настолько расточительное. Однако, этот код иллюстрирует использование динамической переменной, значение которой является делегатом, который может использоваться как функция. Если тип делегата реализует интерфейс IDynamicMetaObjectProvider, то метод BindInvoke из DynamicMetaObject будет использован для байндинга операции к реальной работе. Это происходит из-за того, что компилятор распознает, что динамический объект Write синтаксически используется как функция. Теперь рассмотрим другой участок кода для понимания того, когда компилятор сгенерирует BindInvokeMember:

class Writer :
  IDynamicMetaObjectProvider {
  public void Write(int n) {
    Console.WriteLine(n);
  }
  // реализация интерфейса опущена
}
    
void Main() {
  dynamic Writer = new Writer();
  Writer.Write(7);
}

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

Важная для понимания вещь заключается в том, что компилятор распознает, что Writer.Write(7) — это операция доступа к элементу. То, что мы обычно называем «оператор точка» в C# формально называется «оператор доступа к члену типа». DLR код, сгенерированный компилятором в этом случае, в конечном итоге вызовет BindInvokeMember, в который передаст строку Write и число-параметр 7 в операцию, которая способна выполнить вызов. Если коротко, то BindInvoke используется для вызова динамического объекта как функции, тогда как BindInvokeMember используется для вызова метода как элемента динамического объекта.

Доступ к свойствам через DynamicMetaObject

Из приведенных примеров видно, что компилятор использует синтаксис языка для того, чтобы определить, какие операции DLR байндинга должны быть выполнены. Если вы используете Visual Basic для работы с динамическими объектами, то будет использована его семантика. Оператор доступа (точка), конечно, нужен не только для доступа к методам. Вы можете использовать его для доступа к свойствам. Метаобъект DLR предоставляет три метода для доступа к свойствам динамических объектов:

  1. BindGetMember — получить значение свойства
  2. BindSetMember — установить значение свойства
  3. BindDeleteMember — удалить элемент

Назначение BindGetMember и BindSetMember должно быть очевидным. Тем более теперь, когда вы знаете, как они связаны с тем, как .NET работает со свойствами. Когда компилятор вычисляет get («чтение») свойства динамического объекта, он использует вызов BindGetMember. Когда компилятор вычисляет set («запись»), он использует BindSetMember.

Представление объекта как массива

Некоторые классы являются контейнерами для экземпляров других типов. DLR знает, как обрабатывать такие случаи. Каждый «массиво-ориентированный» метод метаобъекта имеет постфикс «Index»:

  1. BindGetIndex — получить значение по индексу
  2. BindSetIndex — установить значение по индексу
  3. BindDeleteIndex — удалить значение по индексу

Для понимания каким образом используются BindGetIndex и BindSetIndex, представьте класс-обертку JavaBridge, который способен загружать файлы с Java классами и позволяет использовать их из .NET кода без особых сложностей. Такая обертка может использоваться для загрузки Java класса Customer, который содержит некоторый ORM код. Метаобъект DLR может быть использован для вызова этого ORM кода из .NET в классическом C# стиле. Ниже пример кода, который показывает, как JavaBridge может работать на практике:

JavaBridge java = new JavaBridge();
dynamic customers = java.Load("Customer.class");
dynamic Jason = customers["Bock"];
Jason.Balance = 17.34;
customers["Wagner"] =
     new Customer("Bill");

Поскольку в третьей и пятой строчке используется оператор доступа по индексу ([]), компилятор распознает это и использует методы BindGetIndex и BindSetIndex при работе с метаобъектом, возвращенным из JavaBridge. Подразумевается, что имплементация этих методов у возвращенного объекта будет запрашивать исполнение метода у JVM через Java Remote Method Invocation (RMI). В этом сценарии DLR выступает в роли моста между C# и другим языком со статической типизацией. Надеюсь, это проясняет, почему я назвал DLR «языком языков».

Метод BindDeleteMember, точно так же как и BindDeleteIndex, не предназначен для использования из языков со статической типизацией типа C# и Visual Basic, так как они не поддерживают саму концепцию. Однако, вы можете договориться считать «удалением» какую-нибудь операцию, выразимую средствами языка, если это будет вам полезно. Например, вы можете имплементировать BindDeleteMember как обнуление элемента по индексу.

Преобразования и операторы

Последняя группа методов метаобъектов DLR касается обработки операторов и преобразований.

  1. BindConvert — сконвертировать объект в другой тип
  2. BindBinaryOperation — применение бинарного оператора над двумя операндами
  3. BindUnaryOperation — применение унарного оператора над одним операндом

Метод BindConvert используется, когда компилятор понимает, что объект нужно преобразовать к другому известному типу. Неявное преобразование происходит, когда результат динамического вызова присваивается переменной со статическим типом. Например, в следующем C# примере, присвоение переменное y приводит к неявному вызову BindConvert:

dynamic x = 13;
int y = x + 11;

Методы BindBinaryOperation и BindUnaryOperation используются всегда, когда встречаются арифметические операции ("+") или инкременты ("++"). В примере выше, сложение динамической переменной х с константой 11 приведет к вызову метода BindBinaryOperation. Запомните этот маленький пример, мы используем его в следующем разделе чтобы грокнуть другой ключевой класс DLR, называющийся CallSite.

Динамическая диспетчеризация при помощи CallSite

Если ваше знакомство с DLR не заходило дальше использования ключевого слова dynamic, то вы, вероятно, никогда бы не узнали о существовании типа CallSite в .NET Framework. Этот скромный тип, формально известный как CallSite<T>, располагается в пространстве имен System.Runtime.CompilerServices. Это «источник силы» метапрограммирования: он заполнен всевозможными методами оптимизации, которые делают динамический .NET код быстрым и эффективным. Я упомяну аспекты производительности CallSite<T> в конце статьи.

Большая часть того, чем CallSite занимаются в динамическом .NET коде, затрагивает генерацию и компиляцию кода в рантайме. Важно заметить, что класс CallSite<T> лежит в пространстве имен, которое содержит слова "Runtime" и "CompilerServices". Если DLR это «язык языков», то CallSite<T> — это одна из важнейших его грамматических конструкций. Давайте рассмотрим наш пример из предыдущего раздела ещё раз чтобы поближе познакомиться с CallSite и тем, как компилятор внедряет их в ваш код.

dynamic x = 13;
int y = x + 11;

Как вы уже знаете, для выполнения этого кода будут неявно вызваны методы BindBinaryOperaion и BindConvert. Вместо того, чтобы показывать вам длинный листинг дизассемблированного MSIL кода, сгенерированного компилятором, я составил схему:

Грокаем DLR - 3

Помните, что компилятор использует синтаксис языка для того, чтобы определить, какие методы динамического типа исполнять. В нашем примере выполняются две операции: добавление переменной x к числу (Site2) и приведение результата к int (Site1). Каждое из этих действий превращается в CallSite, который хранится в специальном контейнере. Как вы можете видеть на схеме, CallSites создаются в обратном порядке, но вызываются при этом в правильном.

На рисунке вы можете увидеть, что методы метаобъекта BindConvert и BindBinaryOperation вызываются непосредственно перед операциями «создать CallSite1» и «создать CallSite2». Тем не менее, привязанные операции исполняются только в самом конце. Надеюсь, визуализация помогает вам понять, что байндинг методов и их вызов — разные операции в контексте DLR. Более того, байндинг происходит только единожды, тогда как вызов происходит столько раз, сколько понадобится, переиспользуя уже инициализированные CallSites для оптимизации производительности.

Идем по легкому пути

В самом сердце DLR используются деревья выражений для генерирования функций, привязанных к двенадцати методам байндинга, представленным выше. Многие разработчики постоянно сталкиваются с деревьями выражений, используя LINQ, но только немногие из них имеют достаточно глубокий опыт чтобы полноценно имплементировать IDynamicMetaObjectProvider контракт. К счастью, .NET Framework содержит базовый класс, называющийся DynamicObject, который берет на себя большую часть работы.

Чтобы создать свой собственный динамический класс, вам достаточно унаследоваться от DynamicObject и реализовать следующие двенадцать методов:

  1. TryCreateInstance
  2. TryInvokeMember
  3. TryInvoke
  4. TryGetMember
  5. TrySetMember
  6. TryDeleteMember
  7. TryGetIndex
  8. TrySetIndex
  9. TryDeleteIndex
  10. TryConvert
  11. TryBinaryOperation
  12. TryUnaryOperation

Имена методов выглядят знакомо? Должны, ведь вы только что закончили изучать элементы абстрактного класса DynamicMetaObject, в число которых входят методы типа BindCreateInstance и BindInvoke. Класс DynamicMetaObject предоставляет реализацию для IDynamicMetaObjectProvider, который возвращает DynamicMetaObject из своего единственного метода. Операции, связанные с базовой реализацией метаобъекта, просто делегируют свои вызовы методам, начинающимся с «Try» у экземпляра DynamicObject. Все, что вам нужно сделать, — перегрузить методы типа TryGetMember и TrySetMember в классе, унаследованном от DynamicObject, тогда как метаобъект возьмет на себя всю грязную работу с деревьями выражений.

Кэширование

[Подробнее про кэширование можно прочитать в моей предыдущей статье по DLR]

Наибольшую обеспокоенность при работе с динамическими языками у разработчиков вызывает производительность. DLR принимает экстраординарные меры, чтобы развеять эти переживания. Я кратко упомянал тот факт, что CallSite<T> находится в пространстве имен, называющемся System.Runtime.CompilerServices. В этом же пространстве имен лежит несколько других классов, которые осуществляют разноуровневое кэширование. Используя эти типы, DLR реализует три основных уровня кэширования для ускорения динамических операций:

  1. Глобальный кэш
  2. Локальный кэш
  3. Полимофрный кэш делегатов

Кэш используется для того, чтобы избежать лишнюю трату ресурсов на создание байндинга для конкретному CallSite. Если два объекта типа string переданы в динамический метод, который возвращает int, то глобальный или локальный кэш сохранит получившийся байндинг. Это существенно упростит последующие вызовы.

Кэш делегатов, который находится внутри самого CallSite, называется полиморфным, потому что эти делегаты могут принимать разные формы в зависимости от того, какой динамический код выполняется и какие правила из других кэшей использовались для их генерации. Кэш делегатов также иногда называют инлайн-кэшем. Причина использования этого термина в том, что выражения, сгенерированные DLR и их байндеры превращаются в MSIL код, который проходит через JIT-компиляцию, как и любой другой .NET код. Компиляция во время исполнения происходит одновременно с «нормальным» выполнением вашей программы. Понятно, что превращение на лету динамического кода в скомпилированный MSIL код во время выполнения программы может очень сильно повлиять на производительность приложения, так что механизмы кэширования жизненно необходимы.

Автор: Alexey Plokhikh

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js