Упаковка значимых типов в С#. Что под капотом? - Блог ITVDN
ITVDN: курсы программирования
Видеокурсы по
программированию

Заказать звонок

Выбери свою IT специальность

Подписка

Заказать звонок

+38 099 757 27 82

Упаковка значимых типов в С#. Что под капотом?

advertisement advertisement

Недавно у меня была действительно интересная дискуссия с участником команды, который является экспертом по системам типов в .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, поэтому он пропускает формальности и вызывает функцию напрямую. Это как оптимизация производительности, но также с заметным побочным эффектом.

Вывод

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

Вы найдёте полный код в этом сообщении.

Источник

КОММЕНТАРИИ И ОБСУЖДЕНИЯ
advertisement advertisement

Покупай подпискус доступом ко всем курсам и сервисам

Библиотека современных IT знаний в удобном формате

Выбирай свой вариант подписки в зависимости от задач, стоящих перед тобой. Но если нужно пройти полное обучение с нуля до уровня специалиста, то лучше выбирать Базовый или Премиум. А для того чтобы изучить 2-3 новые технологии, или повторить знания, готовясь к собеседованию, подойдет Пакет Стартовый.

Стартовый
  • Все видеокурсы на 3 месяца
  • Тестирование по 10 курсам
  • Проверка 5 домашних заданий
  • Консультация с тренером 30 мин
59.99 $
Оформить подписку
Базовый
  • Все видеокурсы на 6 месяцев
  • Тестирование по 16 курсам
  • Проверка 10 домашних заданий
  • Консультация с тренером 60 мин
89.99 $
Оформить подписку
Премиум
  • Все видеокурсы на 1 год
  • Тестирование по 24 курсам
  • Проверка 20 домашних заданий
  • Консультация с тренером 120 мин
169.99 $
Оформить подписку
Notification success
Мы используем cookie-файлы, чтобы сделать взаимодействие с нашими веб-сайтами и услугами простым и значимым.