Недавно у меня была действительно интересная дискуссия с участником команды, который является экспертом по системам типов в .NET. В ходе беседы он указал на интересный аспект упаковки типа значений в .NET при использовании ограничений. Заинтригованный этой дискуссией, я решил рассмотреть вопрос подробнее.
Основы
Прежде чем углубиться в подробности, давайте рассмотрим некоторые основы и посмотрим, какую роль может играть упаковка при вызове методов типа значение.
Предположим, у нас есть следующий код:
Здесь нет ничего особенного. Foo - это структура, которая имеет целочисленное поле value. Она приватно реализует метод интерфейса, который пытается изменить значение этого поля, а также обычный метод, который делает то же самое.
Теперь, если у нас есть следующий код:
Какое правильное значение после вызова AddValue и после вызова Add?
Если вы знакомы с языком, это, пожалуй, вовсе не удивит вас.
Но давайте немного углубимся и посмотрим, как JIT это делает:
Давайте сначала посмотрим на вызов AddValue.
Обратите внимание, что я показываю код сборки x64, который гораздо проще понять. Первые 4 аргумента всегда передаются в регистре rcx, edx, r8, r9 (остаток передается через стек), а возвращаемое значение возвращается в rax. Все это 64-разрядные регистры. В приведенном выше коде JIT передает указатель «this» в rcx (указывает на часть стека, начиная с rbp-18h, и целое число 10 (0x0a) в rdx / edx (edx - это просто нижняя 32-битная часть rdx).
Теперь, если вы посмотрите на фактический код Foo.AddValue:
Не стесняйтесь игнорировать некоторые из отладочной тарабарщины (clr! JIT_DbgIsJustMyCode). Если вы следите за моими комментариями в сборке (начиная с ;), вы можете увидеть, что 10 добавляется в первую 4-байтовую ячейку памяти в 'this', что и должно делать value + = val.
И вы получите следующее:
Вызов интерфейса в методе экземпляра типа значения.
Теперь давайте взглянем на вызов интерфейса - он немного усложняется:
Опять-таки, я поставил комментарии по правой стороне кода сборки. Он в основном создает упакованную Foo, копирует значение во вновь созданную упакованную Foo. Обратите внимание, что смещение 8 для указателя MethodTable в начале объекта имеют только те объекты и упакованные типы значений, которые являются объектами, разумеется. У обычных типов значений его нет.
На данный момент проигнорируйте весь код отправки интерфейса (это не относится к нашему обсуждению), в конце концов вы придете к некоторым интересным инструкциям ниже:
Этот код, на самом деле, мало что делает. Но он дает нам понимание того, как система работает в целом. Глядя на старый код, который мы показали ранее для метода AddValue, ожидаемо, что он укажет на первое поле. Однако все объекты для поддержки операций типа (таких как рефлексия, кастинг и т. д.) имеют свое первое поле размера указателя в качестве указателя типа, который в языке CLR называется MethodTable. Таким образом, CLR необходимо сгенерировать unboxing-заглушку, которая распаковывает запакованное значение и вызывает базовый JIT-метод, который предполагает работу с распакованным указателем this. Обратите внимание, что распаковка не включает копирование, она просто добавляет к ней смещение. Это фактически означает, что операция + = вступит в силу в упакованной копии. Однако поскольку упакованная Foo известна только компилятору, обновленное значение всегда теряется. И именно поэтому вы увидите:
Видео курсы по схожей тематике:
В случае наличия обобщений
Теперь давайте добавим некоторые обобщения в микс:
Несмотря на то, что это фантастический универсальный метод, сам вызов и лежащий в его основе код не имеет ничего удивительного. Как вы могли ожидать, несмотря на то, что вызывающий элемент передает Foo по ссылке, Add_WithoutConstraint создает его копию, прежде чем вызывает в IAdd, и модификация снова навсегда потеряна.
Добавление ограничений
Теперь интересный случай, о котором я хотел поговорить ранее в этой статье (спасибо, что остались со мной до сих пор!). Давайте создадим обобщенный метод с общим ограничением, где T является интерфейсом IAdd:
Возможно, это не совсем очевидно для всех, foo.Add (val) - это вызов интерфейса с помощью инструкции callvirt: callvirt instance void IAdd :: Add (int32), потому что это единственный способ, который знает компилятор для вызова интерфейса.
Интересной частью является то, что, когда мы вызываем Add_WithConstraints, вызов происходит точно таким же образом, за исключением того, что код, который мы вызываем, радикально отличается:
Как вы видите, код удивительно прост. Без упаковки, без трансляции интерфейса и прямого вызова метода Foo.IAdd.Add. Никакое значение не теряется. И вы можете наблюдать побочный эффект:
Причина в том, что компилятор теперь имеет достаточно информации, чтобы понять, что код для Foo и вызов интерфейса попадет именно на Foo.IAdd.Add, поэтому он пропускает формальности и вызывает функцию напрямую. Это как оптимизация производительности, но также с заметным побочным эффектом.
Бесплатные вебинары по схожей тематике:
Вывод
Когда вы работаете с интерфейсом по типам значений, обратите внимание на потенциальные затраты производительности на упаковку и проблему корректности отсутствия видимых изменений. Если вы хотите избежать этого, вы можете использовать общие ограничения для ограничения вызова интерфейса, чтобы компилятор мог полностью оптимизировать вызов упаковки и интерфейса, и сразу перейти к правильной функции.
Вы найдёте полный код в этом сообщении.
Источник
Статьи по схожей тематике