На первый взгляд, dynamic в C# — просто object с поддержкой машинерии компилятора. Но не совсем.

Ядром времени выполнения является DLR (Dynamic Language Runtime) — подсистема/фреймворк для поддержки динамических языков программирования. Существует реализация под собственно C#, который идет в поставке с .NET, и отдельная для Iron-языков.

Когда мы работаем с обобщениями (generics), то CLR имеет свои оптимизации на предмет специализации оных. В тот момент, когда CLR+DLR должны работать с generics вместе, поведение написанного кода может стать непредсказуемым.

Preamble


Для начала необходимо вспомнить как поддерживаются обобщения CLR'ом.
Каждый generic-тип имеет свою реализацию, т.е. отсутствует type-erasure. Но для ссылочных типов среда использует тип System.__Canon для шаринга кода. Это необходимо не столько из-за очевидности (каждый объект — ссылка размером машинное слово), сколько для разрешения циклической зависимости между типами.

Об этом я уже писал:
Дело в том, что generic-типы могут содержать циклические зависимости от других типов, что чревато бесконечным созданием специализаций для кода. Например:
Generics cyclomatic dependencies
class GenericClassOne<T>
{
    private T field;
}

class GenericClassTwo<U>
{
    private GenericClassThree<GenericClassOne<U>> field
}

class GenericClassThree<S>
{
    private GenericClassTwo<GenericClassOne<S>> field
}
class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine((new GenericClassTwo<object>()).ToString());
        Console.Read();
    }
}


Однако этот код не упадет и выведет GenericClassTwo`1[System.Object].

Type loader (он же загрузчик типов) сканирует каждый generic-тип на наличие циклической зависимости и присваивает очередность (т.н. LoadLevel для класса). Хотя все специализации для ref-types имеют System.__Canon как аргумент типа — это следствие, а не причина.

Фазы загрузки (они же ClassLoadLevel):

enum ClassLoadLevel
{
    CLASS_LOAD_BEGIN,
    CLASS_LOAD_UNRESTOREDTYPEKEY,
    CLASS_LOAD_UNRESTORED,  
    CLASS_LOAD_APPROXPARENTS,
    CLASS_LOAD_EXACTPARENTS,
    CLASS_DEPENDENCIES_LOADED,
    CLASS_LOADED,
    CLASS_LOAD_LEVEL_FINAL = CLASS_LOADED,
};


Infinite loop


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

Рассмотрим иерархию классов:
NB: код реальный — из проекта structuremap, хоть и претерпевший к этому моменту изменения. Пример использовался во время моего выступления «Эффективное использование DLR».

public class LambdaInstance<T> : LambdaInstance<T, T>
{
}

public class LambdaInstance<T, TPluginType> 
    : ExpressedInstance<LambdaInstance<T, TPluginType>, T, TPluginType>
{
}

public abstract class ExpressedInstance<T>
{
}

public abstract class ExpressedInstance<T, TReturned, TPluginType> : ExpressedInstance<T>
{
}

И непосредственно код:

class Program
{
    static LambdaInstance<object> ShouldThrowException(object argument)
    {
        throw new NotImplementedException();
    }

    static void Main(string[] args)
    {
        // будет ли брошено исключение?
        ShouldThrowException((dynamic)new object());
    }
}

Вопрос: будет ли брошено исключение?
Ответ: нет. Метод ShouldThrowException никогда не завершится. И stackoverflow (переноса на сайт) не произойдет.

Хм… Так в чем же дело? — спросите Вы.
Все просто — LambdaInstance<object>. Рассмотрим иерархию классов еще раз.

LambdaInstance<T> наследуется от LambdaInstance<T, TPluginType>, который в свою очередь от ExpressedInstance<LambdaInstance<T, TPluginType>, T, TPluginType>.

Вложенное наследование заметили?

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

Для выражения ShouldThrowException((dynamic)new object()); DLR должен проинспектировать участок кода/сигнатуру метода. В этом процессе встречается LambdaInstance<object> и код превращается в бесконечный цикл.

Почему не крешится? DLR не использует рекурсию. Более того, потребление памяти растет (ибо создаются доп. метаданные), но не сильно.

Epilog


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

Комментарии (13)


  1. Shablonarium
    09.04.2016 16:07

    Это перевод?


    1. szKarlen
      09.04.2016 16:11
      +3

      нет, а почему так решили?


      1. dkukushkin
        10.04.2016 05:31

        Видимо потому что большая часть статей с глубоким капанием — заморские.


        1. xaizek
          10.04.2016 12:11
          +2

          Я думаю всё проще: название и заголовки на английском написаны.


  1. MonkAlex
    09.04.2016 16:34
    +1

    Я правильно понял, что без динамика эта рекурсия будет отрезовлена и выбросится исключение?

    Выглядит страшно, фиг так сообразишь, чего он повис.


    1. szKarlen
      09.04.2016 16:34

      да, именно так


  1. Saladin
    09.04.2016 16:40

    Спасибо за статью!
    Но у меня осталась пара вопросов:

    1. DLR и CLR используют один и тот же TypeLoader?
    2. Почему DLR не использует рекурсию? Это какое-то фундаментальное ограничение, или команда DLR планирует реализовать эту функциональность в будущем?


    1. szKarlen
      09.04.2016 17:22
      +1

      1. TypeLoader один и тот же
      2. На самом деле это не ограничение. Нерекурсивный (почти) алгоритм, используемый в DLR дает одновременно и плюс, и минус в виде отсутствии переполнения стека. К сожалению, разработка непосредственно DLR идет неактивно и только в ветке IronPython. По данному вопросу новостей нет


      1. Saladin
        09.04.2016 18:23

        А не подскажите где можно найти исходный код собственно самого TypeLoader'a?

        Я полазил по проекту coreclr и исходникам .NET'a, но нашел, только вот это:


        Но оно все как-то мало подходит под то, о чем вы говорите.


        1. szKarlen
          09.04.2016 19:05
          +1

          Код загрузки типов в основном сосредоточен в секции vm. Несколько размазан, но конкретно по теме: загрузка generics. Вместо метода CheckInstantiationForRecursion из проекта Rotor (sscli20) код стал более ООП-шным и теперь используется граф (RecursionGraph).

          кстати, по архитектуре загрузчика типов более подробно.


  1. netaholic
    09.04.2016 19:06

    Здравствуйте. Спасибо за интересную статью.
    А не могли бы вы поподробнее объяснить вот эти слова:
    Type loader (он же загрузчик типов) сканирует каждый generic-тип на наличие циклической зависимости и присваивает очередность (т.н. LoadLevel для класса). Хотя все специализации для ref-types имеют System.__Canon как аргумент типа — это следствие, а не причина.
    Когда я писал кодогенерацию на Mono.Cecil с использованием generic'ов — ни с чем таким не сталкивался.


    1. szKarlen
      09.04.2016 19:15
      +2

      Тип System.__Canon никогда не виден ни при написании MSIL'a, ни при приведении типов. Данная особенность является внутренней оптимизацией CLR и может не применяться др. реализациями. так это позволяет упростить сам процесс выявления зависимостей между типами загрузчиком, т.к. происходит трекинг лишь System.__Canon.

      p.s.
      Это никак не влияет на кодогенерацию MSIL будь то Mono.Cecil, либо Reflection.Emit и т.п.


  1. Mixim333
    10.04.2016 17:26

    Спасибо, познавательно.

    «В следующий раз мы рассмотрим пример, где его использование — правильно.» — вот это больше всего интересно. Сам всегда стараюсь избегать типа dynamic — боюсь сам себе «в ногу выстрелить», хотя тип очень интересный. К тому же, читал, по-моему, у Рихтера, что работа с dynamic добавляет кучу накладных расходов и что его не рекомендуется использовать