Продолжается подписка на наши издания! Вы не забыли подписаться?

Dynamic в С#

Автор: Sam Ng
Опубликовано: 12.07.2010
Версия текста: 1.1

Однажды, забавляясь с каким-то офисным кодом, я обнаружил, что пишу много кода, похожего на пример, использованный Андерсом в его выступлении на PDC:

static void Main(string[] args)
{
    var xl = new Excel.Application();

    ((Excel.Range)xl.Cells[1, 1]).Value2 = "Имя процесса";
    ((Excel.Range)xl.Cells[1, 2]).Value2 = "Занято памяти";
}

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

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

static void Main(string[] args)
{
    var xl = new Excel.Application();

    xl.Cells[1, 1].Value2 = "Имя процесса";
    xl.Cells[1, 2].Value2 = "Занято памяти";
}

Так вот, в C# 4.0 появится возможность писать именно так. Одна из основных возможностей, над которыми мы работаем в C# 4.0, представляет собой механизм динамического позднего связывания. Этот механизм дает возможность сообщать компилятору, что возвращаемое значение на самом деле должно рассматриваться как динамический тип и что любое обращение к нему должно быть динамическим. Тогда среда выполнения произведет связывание, основываясь на runtime-типе объекта, а не на статическом типе, возвращаемом во время компиляции. И если связывание пройдет успешно, код будет работать. И это – именно то, что нам нужно.

Каким же образом работает этот механизм?

Во-первых, мы ввели в систему типов некий тип dynamic. Этот тип указывает компилятору, что все операции, основанные на типе, должны связываться динамически, а не во время компиляции. Во-вторых, мы создали C#- runtime binder (компонент, производящий связывание во время исполнения), который осуществляет позднее связывание. И, в-третьих, мы отладили DLR (http://www.codeplex.com/dlr) и обеспечили полноценное использование кэширования и возможностей динамического вызова, так что появилась возможность работать с динамическими объектами (например, объектами, созданными на Iron Python).

Тип dynamic

Чтобы начать использовать динамическое связывание, необходимо каким-то образом указать компилятору, что мы хотим связать наш объект или выражение динамически.

Тип dynamic – всего лишь обычный тип. Его можно использовать в коде для объявления локальных переменных, полей, возвращаемых значений методов и т.п. Он сообщает компилятору, что работать с этим конкретным объектом или выражением следует динамически. Рассмотрим пример:

static void Main(string[] args)
{
    dynamic d = SomeInitializingStatement;

    d.Foo(1, 2, 3);       // (1)
    d.Prop = 10;          // (2)
    var x = d + 10;       // (3)
    int y = d;            // (4)
    string y = (string)d; // (5)
    Console.WriteLine(d); // (6)
}

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

  1. Поскольку получатель в этом примере имеет тип dynamic, компилятор укажет среде выполнения, что ей следует связать некий метод «Foo» с тем runtime-типом, который имеет d, применив к нему аргументы {1, 2, 3}.
  2. Получатель в данном примере также является динамическим, поэтому компилятор указывает среде выполнения, что ей следует связать нечто, похожее на свойство, с названием «Prop» (это может быть поле или свойство), и что ему нужно присвоить значение 10.
  3. В этом примере оператор «+» становится динамически связываемой операцией, потому что один из его аргументов имеет динамический тип. И среда выполнения осуществляет нормальное разрешение правил перегрузки (resolution rules) для оператора «+», находя любые определенные пользователем операторы с именем «+», runtime-типа переменной d, и принимая их во внимание вместе с обычными бинарными операторами, предопределенными для типа int.
  4. В этом примере имеется неявное преобразование из runtime-типа переменной d в тип int. Компилятор оповещает среду выполнения, что ей следует рассмотреть все неявные преобразования int и runtime-типа переменной d и определить, имеется ли преобразование в int.
  5. Здесь подчеркивается явное преобразование в строку. Компилятор расшифровывает это преобразование и указывает среде выполнения рассмотреть явные преобразования в строку.
  6. В этом примере также используются динамические аргументы, несмотря на то, что вызывается метод, статически известный во время компиляции. Мы не можем правильно разрешить перегрузку как таковую во время компиляции, так что «динамичность» переменной d влияет на включающий ее вызов, и в конце оказывается, что мы диспетчеризуем Console.WriteLine также динамически.

Существует несколько других сценариев, в которых участвует dynamic. Именно эти я перечислил для того, чтобы дать вам общую идею о том, что имеется в виду под динамическим типом.

Теперь следует отметить, что тип dynamic – просто синтаксическое выражение, призванное указать компилятору, что связывания следует выполнять динамически. В метаданных тип dynamic является всего лишь типом object с атрибутом, указывающим на его динамичность. (если это можно так назвать… Но, думаю, вряд ли!).

Что происходит во время компиляции?

Для каждой динамической операции компилятор генерирует вызовы в DLR и использует его точки вызова (call sites). Чтобы побольше узнать о DLR и точках вызова, обратитесь к отличному докладу моего коллеги Джима Хугунина на PDC. Блог его находится по адресу http://blogs.msdn.com/hugunin/, а собственно доклад можно посмотреть здесь: http://chan­nel9.ms­dn.com/­pd­c­­2008/TL10/.

Точка вызова DLR берет набор стандартных действий, указывающих, какой тип динамической операции нам нужен. Компилятор C# создает подкласс этих стандартных действий, снабженных некими специфичными для C# деталями, и, вместо вызова, выполняемого пользователем, производит обращения из своих точек вызова. Например, наш код переводится в нечто, похожее вот на такой псевдокод:

// Этот код...
static void Main(string[] args)
{
    dynamic d = SomeInitializingStatement;
    d.Foo(1, 2, d);
}

// преобразуется в этот код.
static void Main(string[] args)
{
    dynamic d = SomeInitializingStatement;
    _csharpCallAction = new CSharpCallAction("Foo");
    _dlrSite<T> = new Site<T>(_csharpCallAction); // Создать  точку вызова. 
    _dlrSite.Target(1, 2, d); // Вызвать делегата. 
}

Если хотите узнать об этом больше, обратитесь к прекрасному блогу моего коллеги Криса Барроуза (http://blogs.msdn.com/cburrows/archive/2008/10/27/c-dy­na­mic.aspx). Это блог о динамическом типе и о том, что генерирует компилятор.

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

Вызов этого делегата приводит к вызову runtime binder-а C#, который связывает выражение, основанное на runtime-типах этих аргументов и этого получателя.

Что происходит во время выполнения программы?

Когда DLR-делегат получает вызов, он делает парочку замечательных вещей, которые я опишу вкратце. Для более полной информации читайте блог Джима Хугунина.

  1. DLR проверяет кэш, чтобы посмотреть, происходит ли связывание с тем же списком аргументов. Так, в нашем примере мы бы сопоставили типы на основании типов аргументов (1, 2, и runtime-тип «d»). Если это найдется в кэше, мы вернем кэшированный результат.
  2. В случае промаха мимо кэша, DLR проверит, реализует ли получатель интерфейс IDynamicObject. Вообще-то, это объекты, которые сами выполняют связывание: к примеру, COM-объекты, реализующие интерфейс IDispatch, по-настоящему динамические объекты (как Ruby- или Python-объекты) или некоторые объекты .NET, реализующие интерфейс IDynamicObject. Если это один из таких объектов, DLR запросит у него IDynamicObject и попросит его связать операцию.

Обратите внимание, что результатом вызова IDynamicObject для связывания является дерево выражений, представляющее результат связывания.

  1. Если это не IDynamicObject, то для связывания операции DLR обратится к binder-у языка (в нашем случае это runtime binder). Runtime binder С# свяжет операцию и вернет дерево выражений, представляющее результат связывания.
  2. Когда шаг 2 или 3 будет выполнен, дерево выражений, полученное в результате связывания, помещается в кэширующий механизм, так что любой последующий вызов вместо того, чтобы быть связанным заново, будет выполнен из кэша.

Джим дает прекрасное описание пунктов 1, 2, и 4, разъясняющих особенности DLR, а я остановлюсь на пункте 3.

Runtime binder С#

Runtime binder С# использует рефлексию для заполнения своей внутренней таблицы, чтобы установить, что с чем связывать. Каждая отдельная операция в C# кодирует тип связывания вместе с дополнительной информацией, и это позволяет нам определить, как связывать операцию.

К примеру, если во время компиляции известно, что аргумент имеет статический тип, этот тип будет маркирован в операции C# и во время runtime-связывания использован как тип аргумента. Если во время компиляции известно, что тип аргумента – dynamic (то есть, он либо является переменной динамического (dynamic) типа, либо выражением, возвращающим dynamic), тогда runtime binder задействует для определения его runtime-типа рефлексию и использует этот тип как тип аргумента.

Runtime binder заполняет символьную таблицу согласно требованиям. Так, в нашем примере мы вызываем метод Foo. Runtime binder загрузит в символьную таблицу все элементы, именуемые Foo, для типа получателя.

После этого он заполнит нужные преобразования для каждого типа аргументов. Поскольку нам может понадобиться привести типы аргументов к типам, соответствующим вызовам методов (применяя, при необходимости, преобразования, определенные пользователем), runtime binder загрузит в символьную таблицу и эти преобразования.

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

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

II. Основы

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

То, что делает компилятор, можно разделить на три части: объявление типов и элементов с динамическими типами (т.е. методы, которые возвращают dynamic), связывание и поиск имен, и генерирование кода. Рассмотрим сначала аспекты связывания dynamic.

Динамическое связывание

Само динамическое связывание может иметь два сценария. Возьмем пример:

static void Main(string[] args)
{
    dynamic d = 10;
    C c = new C();

    // (1) Динамические приемники.
    d.Foo(); // Вызов.
    d.PropOrField = 10; // Свойство.
    d[10] = 10; // Индексатор.

    // (2) Статически типизированные приемники (или статические методы)
    //     с динамическими аргументами.
    c.Foo(d); // Вызов метода у объекта
    C.StaticMethod(d); // Вызов статического метода.
    c.PropOrField = d; // Свойство.
    c[d] = 10; // Индексатор.
    d++; // Считайте, что это op_increment(d).
    var x = d + 10; // Считайте,  что это op_add(d, 10).
    int x = d; // Считайте, что это op_implicit(d).
    int y = (int)d; // Считайте,  что это op_explicit(d).
}

Посмотрите на первый блок этого примера (1). Каждый из этих динамических вызовов происходит из-за динамически типизированного выражения. Понятно, откуда здесь берется динамичность и куда она идет дальше.

Второй блок в примере (2) немного сложнее – в каждом случае dynamic используется неявно. Поскольку аргумент каждой операции динамический, аргументы перетекают во включающие их операции и делают их также динамическими. В сущности, компилятор формирует как бы смесь динамического и статического связываний. Он будет использовать статический тип получателя для определения списка перегрузок, но для выполнения разрешения перегрузки задействует run­time-типы аргументов.

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

Динамические получатели

Когда компилятор встречает выражение, типизированное как dynamic, он знает, что последующую операцию нужно рассматривать как динамическую. Будь это получение или установка данных по индексу, вызов метода и т.п., – тип результата операции определяется во время выполнения программы, и поэтому результат операции во время компиляции также должен иметь тип dynamic.

Компилятор преобразует все динамические операции в нечто, называемое нами динамической точкой вызова (dynamic call site). Это включает в себя создание компилятором статического поля для генерируемого статического класса, который сохраняет экземпляр специального DLR-объекта, используемый для вызова, и инициализирует его.

Точка вызова DLR является обобщенным объектом (generic-объектом), которому в качестве параметра типа передан делегат, описывающий вызов. Более подробно о том, как этот делегат генерируется, будет сказано позже. Имена типов могут не быть определены окончательно, а точка вызова DLR пока создается при помощи CallSiteBinder. Это объект, который знает, как производить конкретное связывание, необходимое точке вызова. DLR предоставляет набор стандартных операций, посредством которых можно использовать поддержку DLR для взаимодействия с динамическими объектами.

Точка вызова включает в себя поле типа T, являющееся экземпляром делегата. Данный делегат содержит кэширующий механизм DLR, о котором вы можете прочитать в блоге Джима Хугунина (http://blogs.ms­dn.com/hugunin). Этот механизм сохраняет результаты каждого связывания и используется для вызова результирующей операции.

Как только точка вызова создана, компилятор формирует код вызова делегата, передавая в него аргументы, которые пользователь передал в точку вызова.

Что происходит во время выполнения программы?

Компилятор создает точку вызова DLR и затем вызывает делегата. Делегат дает возможность DLR производить свою магию. Если у нас нет настоящего IDynamic­Object и попадания в кэш, будет вызван Call­SiteBinder, который был заложен в точке вызова DLR. У С# есть свои собственные наследники CallSiteBinder-а; они знают, как правильно выполнить связывание, и вернут дерево выражений, динамически преобразуемое в делегат, который будет закэширован в точке вызова.

Текущий механизм кэширования просто проверяет совпадение типов аргументов. Например, у нас есть такой вызов:

arg0.M(arg1, arg2, ...);

И допустим, что он имеет arg0.Type == C и что все аргументы, переданные в этот наш вызов, имеют тип int. Проверка кэша будет выглядеть примерно так:

if (arg0.GetType() == typeof(C) 
    && arg1.GetType() == typeof(int) 
    && arg2.GetType() == typeof(int) 
    && ...
    )
{
    Здесь вставить результат связывания CallSiteBinder.
}
... // Дальнейшие проверки кэша
else
{
    Вызвать для связывания CallSiteBinder и обновить кэш.
}
Создание объекта CallSiteBinder в C#

И последнее, что нам нужно для представления полной картины динамического связывания, – это понимание работы CallSiteBinder в C#.

В нашем примере имеется три разных типа динамических операций: вызов, обращение к свойству и индексатор. Каждая из них уникальна, но у них есть много общего в функциональности. Так, все они инициализированы общим runtime binder-ом C# и используются им как объекты данных, описывающие операцию, которую следует связать. Назовем эти объекты C#-метаинформацией, или метаописанием (в оригинале использовалось название C# payloads, но, с нашей точки зрения, это совершенно случайно выбранный термин – прим.ред.).

Удобно считать, что runtime binder C# - своего рода мини-компилятор. В нем много аспектов, присущих традиционным компиляторам: и символьная таблица, и система типов, и большая часть функциональности – например, разрешение перегрузки и подмена типов.

Давайте используем для анализа простой пример d.Foo(1).

Когда вызывается runtime binder, ему дается метаинформация для текущей точки вызова, а также runtime-аргументы, с которыми эта точка связывается. Runtime binder берет типы всех аргументов, включая получателя, и заполняет этими типами свою символьную таблицу. Затем он распаковывает полезную нагрузку, чтобы выяснить название операции, которую следует выполнить в получателе (в нашем случае это «Foo»). Binder использует рефлексию для разгрузки всех элементов, называемых «Foo», из runtime-типа от d, укладывая эти элементы в свою символьную таблицу.

С этого момента во внутренней системе binder-а достаточно информации, чтобы выполнить связывание, которое описывается этой операцией. Именно здесь мы сворачиваем в нужном направлении и производим связывание, основываясь на метаинформации.

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

Каждое метаописание, в сущности, связывается так же, как это делал бы статический компилятор. Результатом связывания оказывается дерево выражений, представляющее операцию, которую следует выполнить, если связывание прошло успешно. Если нет – генерируется исключение runtime binder-а. В случае успеха результирующее дерево выражений преобразуется в делегат точки вызова и становится частью кэширующего механизма DLR. Затем оно вызывается таким образом, что выполняется результат пользовательского динамического связывания.

Небольшое ограничение

Я уже сказал, что мы старались сохранить логический принцип приведения так, словно бы эти действия производил статический компилятор. Существует, однако, несколько сценариев, которые не будут работать в Visual Studio 2010, которую мы надеемся получить в следующем релизе.

В этом контексте следует отметить лямбды, методы-расширения и группы методов.

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

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

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

delegate void D();
public class C
{
    static void Main(string[] args)
    {
        dynamic d = 10;
        D del = d.Foo; // Это связалось бы с группой методов во время выполнения программы. 
    }
}

Поскольку представить группы методов во время выполнения программы невозможно, если runtime binder свяжет d.Foo с группой методов, будет выброшено исключение во время выполнение программы.

III. Небольшое дополнение

Выше мы рассматривали основы динамического связывания. Добавим теперь к этому небольшое дополнение.

Во-первых, вернемся к примеру, который использовался в прошлый раз:

static void Main(string[] args)
{
    dynamic d = 10;
    C c = new C();

    // (1) Динамические получател.
    d.Foo();// Вызов.
    d.PropOrField = 10; // Свойство.
    d[10] = 10; // Индексатор.

    // (2) Статически типизированные получатели (или статические методы)
    //     с динамическими аргументами.
    c.Foo(d); // Вызов метода у объекта.
    C.StaticMethod(d); // Вызов статического метода.
    c.PropOrField = d; // Свойство.
    c[d] = 10; // Индексатор.
    d++; Считайте, что это op_increment(d).
    var x = d + 10; // Считайте,  что это op_add(d, 10).
    int x = d; // Считайте,  что это op_implicit(d).
    int y = (int)d; // Считайте,  что это op_explicit(d).
}

В прошлый раз мы работали с первым набором вызовов – с теми, что обладают динамическими получателями. Теперь будет рассмотрен второй список: вызовы со статическими получателями или без явно видимого получателя.

Что ожидается?

Возьмем простейший вызов из этого списка и немного его раскроем. Допустим, имеется нечто вот такого вида:

public class C

{
    public void Foo(decimal x) { ... }
    public void Foo(string x) { ... }
    static void Main(string[] args)
    {
        C c = new C();
        dynamic d = 10;
        c.Foo(d);
    }
}

Сначала посмотрим на него чисто интуитивно. Что тут может произойти?

Поскольку тип локальной переменной 'c' известен, мы интуитивно ожидаем, что должен быть вызван один из двух перегруженных методов Foo класса C. Однако мы так же знаем, что переменная d – динамического типа и поэтому вплоть до выполнения программы компилятор не может определить, какой из этих перегруженных методов следует вызвать. Следовательно, мы предполагаем комбинацию этих двух вариантов: во время компиляции компилятор определит список кандидатов для вызова и установит, который из них должен быть вызван во время выполнения программы. Теперь, поскольку во время вызова в d установлено значение 10, мы ожидаем вызова того перегружаемого Foo, который получает значение типа decimal, потому что значение 10 не конвертируемо в тип string.

Чего не ожидается?

Здесь мы будем более определенны и раскроем наш пример так, чтобы увидеть, чего мы НЕ ожидали:

public class C
{
    public void Foo(decimal x) { ... }
    public void Foo(string x) { ... }
    static void Main(string[] args)
    {
        C c = new D();
        dynamic d = 10;
        c.Foo(d);
    }
}

public class D : C
{
    public void Foo(int x) { ... }
}

Прежде всего, обратим внимание на тонкое изменение в нашем исходном коде, выделенное жирным. Мы создаем экземпляр производного класса D. Это означает, что во время выполнения программы локальная переменная c будет экземпляром типа D, а не C, как было в предыдущем примере. Отметьте также, что D включает в себя перегруженный Foo, который подходит лучше, чем все перегруженные методы Foo класса С – ведь значение 10 в действительности относится к типу int, так что метод D.Foo годится для вызова лучше всего.

Примите, однако, во внимание, что, хотя в коде нашего примера создана локальная переменная c, можно легко представить себе метод, принимающий параметр типа C и получающий во время выполнения программы какой-нибудь другой производный класс. Мы не ожидаем, что это изменит наш список кандидатов, используемых для разрешения перегрузки! Если быть конкретнее (в терминологии компилятора), поскольку вызов метода c.Foo может быть статически связан с группой методов, мы ожидаем, что будет использована статически определенная группа методов. Динамический аргумент должен оказывать влияние лишь на выбор одного из группы методов, а не на создание самой этой группы.

Что происходит на самом деле?

Как уже было сказано выше, один из принципов разработки, которого мы стараемся придерживаться, состоит в том, чтобы динамическое связывание вело себя так же, как связывание во время статического компилирования. За исключением одной детали: тип, использованный вместо динамических объектов (аргументов или получателей), является типом, определяемым во время выполнения программы (runtime-типом), а не типом, определяемым во время компиляции. Это означает, что для всех аргументов, которые не типизированы статически как dynamic, будут использованы типы времени компиляции, вне зависимости от их runtime-типов.

В приложении к нашему примеру это означает, что во время выполнения программы нам следует связывать так, будто типом получателя с является С, а типом аргумента d – int. Используя эти типы для разрешения перегрузки, в результате получим C.Foo(decimal).

Вот пример чуть посложнее, из которого (надеюсь) это станет совершенно ясно:

public class C
{
    public void Foo(object x, C c) { ... }
    static void Main(string[] args)
    {
        C c = new D();
        dynamic d = 10;
        c.Foo(d, c);
    }
}

public class D : C
{
    public void Foo(int x, D d) { ... }
}

Обратите внимание, что в данном примере «с» во время выполнения содержит в себе экземпляр типа D, а «d» содержит значение 10. Если бы нам нужно было использовать runtime-типы для всего, вовлеченного в связывание во время выполнения, тогда получатель имел бы тип D с типами аргументов int и D соответственно. Это дало бы D.foo(int, D) в качестве оптимального результата, но это еще не все из того, что мы бы ожидали.

Поскольку только первый аргумент d является статически известным динамически типизированным аргументом, он – единственный, для которого используется его runtime-тип. Для всех остальных аргументов в этом вызове (получатель c и второй аргумент c) используются их статические типы. В сущности, единственный учитываемый здесь метод - C.Foo(object, C), который мы и ожидали после разрешения перегрузки.

Что дальше?

В следующий раз мы рассмотрим подробнее, что делает компилятор для определения, нужна ли для полученного выражения диспетчеризация времени выполнения. Затем приложим изученные нами принципы к другим типам вызовов в нашем примере (например, к операторам и к тому, что выглядит как акцессоры для свойств). К концу мы изучим всю эту динамическую штуку целиком и полностью, так что оставайтесь на связи!

IV. Фантомный метод

Ну да, звучит как в фильме «Звездные войны», но нет, я не фанат «Звездных войн», который то и дело цитирует любимые строчки (хотя сам фильм мне, пожалуй, нравится). Это мое сообщение будет посвящено тому, что мы называем «фантомный метод». Имеется в виду метод, который статический компилятор связывает в начальной фазе связывания, как только узнает, что вызов, который он пытается связать, необходимо связывать динамически, а не разрешать статически. Он использует принципы, о которых мы говорили выше, чтобы определить, какие типы следует использовать во время выполнения.

Рассмотрим простой пример:

public class C
{
    public void Foo(int x)
    {
    }

    static void Main()
    {
        dynamic d = 10;
        C c = new C();
        c.Foo(d);
    }
}

Когда мы пробуем связать вызов Foo, алгоритм разрешения перегрузки в компиляторе собирает список кандидатов, состоящий из единственного кандидата - C.Foo(int). В этот момент мы соображаем, конвертируемы ли аргументы. Но постойте! Мы же еще не говорили о конвертируемости типа dynamic!

Давайте быстренько рассмотрим конвертируемость типа dynamic.

Конвертации динамического типа

Быстрый и легкий путь к пониманию преобразований типа dynamic – думать, что все можно конвертировать в dynamic, а dynamic нельзя конвертировать ни во что. «Погодите-ка! – скажете вы. – В этом же нет никакого смысла!» И будете абсолютно правы. В этом нет никакого смысла. Но только до тех пор, пока мы не говорим об особенном обращении с типом dynamic в ситуациях, где ожидается конвертируемость.

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

Пусть локальная переменная «c» означает некоторую статически типизированную локальную переменную, а переменная «d» означает некоторое динамически типизированное выражение. Особенные ситуации здесь будут таковы:

  1. Разрешение перегрузки – c.Foo(d).
  2. Преобразование при присвоении – C c = d.
  3. Условия - if (d).
  4. Использование операторов – using (dynamic d = ...).
  5. Цикл – foreach (var c in d).

Сегодня мы рассмотрим разрешение перегрузки и изучим концепции, а оставшиеся сценарии оставим в качестве упражнений для читателя. :)

Разрешение перегрузки

Вернемся к конвертируемости аргументов. Поскольку dynamic ни во что не конвертируется, наш аргумент d неконвертируем в int. Тем не менее, раз у нас динамически типизированный аргумент, мы очень хотим, чтобы разрешение перегрузки для этого вызова было связано динамически. Введем фантомный метод.

Фантомный метод – это метод, который вводится в список кандидатов, имеющий такое же количество параметров, как и количество передаваемых аргументов, причем каждый из этих параметров типизирован как dynamic.

Когда фантомный метод введен в список кандидатов, он рассматривается как любая другая перегрузка. Вспомним, что хотя dynamic и не конвертируется в другие типы, зато все другие типы конвертируемы в dynamic. Это означает, что в то время как все (ну, не в самом деле все, но об этом позже) обычные перегрузки не сработают из-за присутствия динамических аргументов, фантомный метод выполнится успешно.

В нашем примере имеется один аргумент, типизированный как dynamic, и два перегруженных метода: Foo(int) и Foo(dynamic). Первый перегруженный метод сбоит, потому что dynamic не конвертируем в int. Второй – фантом – успешен, и мы связываемся с ним.

Когда компилятор связывает вызов с фантомным методом, он генерирует специальный DLR-код, уведомляющий DLR о том, что вызов диспетчеризуется во время выполнения.

Остается только один вопрос: когда именно встраивается фантомная перегрузка?

Встраивание фантомной перегрузки

Когда компилятор выполняет разрешение перегрузки, он рассматривает каждую перегрузку в первоначальном списке кандидатов. Если вызов имеет любые динамические аргументы, тогда для каждого кандидата в начальном списке компилятор проверяет, нужно ли встроить фантомную перегрузку. Фантомная перегрузка будет встроена, если:

  1. Все нединамические аргументы конвертируемы в соответствующие им параметры.
  2. Хотя бы один динамический аргумент неконвертируем в соответствующий ему параметр.

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

Нижеследующий пример не получит в результате динамический поиск, а будет связан статически :

public class C
{
    public void Foo(int x, dynamic y) { ... }

    static void Main()
    {
        C c = new C();
        dynamic d = 10;
        c.Foo(10, d);
    }
}

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

Чем различаются диспетчеризация фантомного метода и диспетчеризация при наличии динамического получателя?

Важно отметить, что существует тонкое различие между диспетчеризацией сигнала из фантомного метода и диспетчеризацией сигнала при наличии динамического получателя.

Если получатель динамический, то перегрузки, которые будут рассматриваться runtime binder-ом, определятся на основании runtime-типа этого получателя. А в случае вызова фантомной метода конкретная перегрузка определяется во время выполнения на основании списка перегрузок, формируемого во время компиляции.

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

Точнее, не ожидается, что будет вызвана какая-то перегрузка в каком-то производном классе (возможно, даже не объявленном в собственном источнике!). И это именно то, что мы приняли во внимание, когда проектировали поведение динамической диспетчеризации.

Итак, что же дальше?

Дальше мы приложим правила и концепции, которые обсуждали сегодня, к другим вызовам: операторам, конвертациям, индексаторам и свойствам.

V. Индексаторы, операторы и прочее!

Свойства

Обращение к свойству имеет форму d.Foo, где d является неким динамическим объектом, а Foo – неким именем какого-нибудь поля или свойства, существующего в runtime-типе переменной d. Когда компилятор с этим сталкивается, он вносит имя «Foo» в метаописание и дает инструкцию runtime binder-у разорвать связь с runtime-типом переменной «d».

Однако обратите внимание, что свойства всегда используются в контексте! С ними можно делать три вещи: получать значение, устанавливать значение, или выполнить оба этих действия (объединенные операции вроде += и т.п.). При этом компилятор кодирует и способ использования в метаинформации, так что среда выполнения позволит вам связаться с доступным только для чтения свойством лишь в том случае, если вы хотите получить к нему доступ на чтение. А если вы попытаетесь установить его значение, она выдаст ошибку.

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

Во время компиляции тип их возвращаемого значения - dynamic.

Индексаторы

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

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

Во время компиляции тип их возвращаемого значения также dynamic.

Конвертации

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

Эти метаописания довольно просты. Поскольку компилятору уже известен тип, в который мы хотим конвертировать (т.е. тип переменной, к которой мы получаем доступ), он просто вносит тип конвертации в метаописание, указывая runtime binder-у, что ему следует попытаться выполнить все неявные (или явные, если это прямое преобразование типов) конвертации из runtime-типа аргумента в тип приемника его значения.

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

Метаописания предоставляют информацию о типе, в который должно быть преобразовано значение при конвертации. Заметьте, что они – единственные, кто во время компиляции имеет нединамический тип.

Операторы

Операторы – немного странные звери. С первого взгляда трудно увидеть, что там происходит что-нибудь динамическое. Однако даже простое выражение типа d+1 должно диспетчеризироваться во время выполнения, потому могут понадобиться операторы, определяемые пользователем.

По существу, любой оператор, имеющий динамический аргумент, должен диспетчеризироваться во время выполнения. Это подразумевает и все операторы типа +=, -= и т.д.

Обратите внимание, какие фокусы произведет компилятор, чтобы понять, выполняется у вас установка значения в член класса (т.е. d.Foo += 10) или в переменную (ie d += 10), и как он решает, нужно ли передавать d по ссылке в точку вызова, чтобы она могла изменяться. Заметьте, что структуры тоже будут изменяться! Так что, если мы хотим сделать нижеследующее:

public struct S
{
    public int Foo;
}

public class C
{
    static void Main()
    {
        dynamic d = new S();
        d.Foo += 10;
    }
}

то в результате d будет указывать на структуру, чей элемент Foo равен 10.

И наконец, компилятор знает, что если выполняется код вроде d.Foo += x, и во время выполнения d.Foo связывается с делегатом или событием, то будет выполнен вызов метода Combine или Add, соответственно.

Вызов делегата

Синтаксис вызова (invocation) очень похож на вызов (call) обычного метода. Разница в том, что имя операции не установлено явно. То есть, как и в случае вызовов (calls), оба примера ниже приводят к диспетчеризации времени выполнения:

public class C
{
    static void Main()
    {
        MyDel  c = new MyDel();
        dynamic d = new MyDel();
        d();
        c(d);
    }
}

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

Второй пример приводит к диспетчеризации времени выполнения, потому что в нем задан динамический аргумент. Во время компиляции компилятор определяет, что имеется вызов делегата, поскольку тип переменной «c» является делегатом, но фактическое разрешение перегрузки должно состояться во время выполнения программы.

VI. Что dynamic НЕ делает

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

Изменение значений при помощи dynamic

Рассмотрим следующий код:

static void Main()
{
    dynamic d = 10;
    d++;
}

Что здесь должно произойти?

Мы интуитивно ожидали, что «d» получит значение 11. Но вспомним, что по своей сути dynamic на самом деле – тип object. Это означает, что первая строчка приведет к генерирированию boxing-а, преобразующего int в object. Локальная переменная d включает в себя boxed-копию указанного целого значения. Тогда вторая строчка, в сущности, представляет собой применение оператора ++ к локальной переменной d. Во время выполнения программы вызываемый оператор ++, примененный к d, приведет к распаковке (unbox) значения в целое число и вызову оператора ++ для распакованного значения. Но это значение не копируется обратно в boxed-копию!

Получается, что, если наивно объявить это в runtime binder-е, ожидаемого на самом деле не произойдет. Хорошо, что ваш верный компилятор C# не наивен!!!

Решение этой небольшой проблемы заключается в том, чтобы передавать значения по ссылке runtime binder-у так, чтобы он мог записать назад значение, являющееся результатом операции. Это решает половину проблемы: у нас все еще остается проблема с boxed-структурами. К счастью, архитектура runtime binder-а такова, что мы возвращаем дерево выражений в DLR. Существует дерево выражений, которое выполняет unboxing, изменение полученного значения и обратный boxing значения, позволяя изменять обернутые значения.

Изменение вложенных структур

Если взять наш пример и перенести его на следующий уровень, ситуация начинает немного усложняться. Наша архитектура такова, что «точечные» выражения разбиваются на части и связываются сегментами. Это значит, что для выражения A.B.C.D компилятор закодирует точку вызова для A.B, использует результат в качестве получателя для второй точки вызова для .С и использует этот результат в качестве получателя для третьей точки вызова для .D.

Такое устройство архитектуры кажется разумным, правда? Действительно, это та же самая архитектура, которую компилятор использует для связывания. Однако runtime-архитектура ограничена тем, что не имеет возможности возвращать значения по ссылке. (Ну, это не совсем ограничение в CLR, потому что у них уже есть для этого поддержка. Это, скорее, ограничение в языках .NET, так как ни в одном из них нет способа возвращать значения по ссылке).

Это означает, что, если любое точечное выражение нужно связать с value-типом, это значение будет обернуто (boxed) (и, соответственно, скопировано), и следующие точки в этом выражении будут сделаны для копии этого значения, а не, как ожидается, для оригинала. Рассмотрим такой код:

public struct S
{
    public int i;
}

public class D
{
    public S s;
    public static void Main()
    {
        dynamic d = new D();
        d.s = default(S);
        d.s.i = 10;
        Console.WriteLine(d.s.i);
    }
}

Интуитивно мы ожидаем увидеть в консоли значение '10', но вместо него появляется значение '0'. Сейчас мы работаем над тем, чтобы найти лучший способ решения этой проблемы, и обсуждаем, настолько ли это критично, что нужно исправлять сценарий.

Правило большого пальца? Помните, что dynamic похож на object, и поэтому с ним случается boxing!

base-вызовы

В CLR существует ограничение, не разрешающее компилятору генерировать невиртуальные вызовы виртуальных методов. Это значит, что нет способа вызвать реализацию виртуального метода, определенную в базовом классе (base-перегрузку), динамически. А это означает, что нельзя выполнять base-вызовы ни с какими динамически типизированными аргументами, потому что это запустит динамическое связывание.

Возможным решением (которое мы решили не применять) является нечто, похожее на решение для лямбд. Вспомним, что если у нас есть лямбда - x => base.M(x), компилятор будет генерировать private-метод, который выполнит вызов с доступом к base, и заставит тело лямбды вызвать генерируемый метод. Недостаток здесь заключается в том, что в случае лямбд мы в точности знали, какой именно вызов пытался сделать пользователь. В динамическом сценарии разрешение перегрузки происходит во время выполнения программы, поэтому пришлось бы генерировать вызов заглушки для базы для каждой возможной перегрузки. Это довольно некрасивое решение, и поскольку у нас сейчас нет абсолютно неразрешимых сценариев, мы предпочли его не применять, а просто выдаем ошибку во время компиляции, если пользователь пытается выполнять base-вызовы с любыми динамическими аргументами.

Явная реализация методов интерфейсов

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

interface IFoo
{
    void M();
}

class C : IFoo
{
    void IFoo.M() { }
}

Благодаря реализации компилятором явно реализуемых интерфейсов, имя C.M удаляется (и он становится невызываемым через С). Это хорошо во время компиляции, потому что компилятор сам видит, когда получатель является указателем на IFoo. Но во время выполнения программы не присутствуют интерфейсы, поэтому нет IFoo, который может использоваться runtime binder-ом для диспетчеризации методов. Учитывая, что имя C.M удалено, данный метод становится совершенно невозможно вызвать динамически.

Доступность (accessibility)

То, что описано выше, еще не ограничение. Мы все еще не можем определиться в выборе между практичностью и последовательностью. Реализация динамического типа в CTP в настоящее время выполняет проверку доступности только для того элемента, к которому вы пытаетесь получить доступ. Это означает, что runtime binder осуществляет проверку, чтобы убедиться, что все элементы, к которым вы пытаетесь получить доступ, являются публичными.

Точнее, мы проверяем доступность для собственно типа (т.е. вам действительно нужно иметь доступ к данному типу объекта в вашем текущем контексте или же он просто должен выглядеть для вас как закрытый объект) и не позволяем элементам, не являющимся публичными, быть использованными динамически.

Недостаток такого сценария в том, что вы можете сделать вызов приватного метода со статическим получателем, если знаете, что способны получить доступ к этому методу из вашего контекста. Но поскольку дается динамический аргумент, runtime binder не позволит вам вызвать этот метод. Вот пример:

public class C
{
    private void M(int x) { }

    static void Main()
    {
        dynamic d = 10;
        C c = new C();
        c.M(d);
    }
}

Когда компилятор столкнется с этим выражением во время компиляции, он произведет проверку и узнает, что C.M является доступным в вызывающем контексте. Но поскольку аргумент здесь динамический, вызов будет разрешен во время выполнения программы. Разрешение перегрузки не свяжет этот метод, потому что в нынешнем binder-е существует политика защиты «только публичные».

Выводы?

Как обычно, я жду ваших отзывов, позитивных или негативных. Разработка не стоит на месте, так что все ваши замечания будут доставлены вашим покорным слугой команде разработчиков лично. Заранее спасибо за комментарии, и как всегда – счастливого кодирования!


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

Copyright © 1994-2016 ООО "К-Пресс"