metrica
Мы используем куки, чтобы пользоваться сайтом было удобно.
Хорошо
to the top
close form

Заполните форму в два простых шага ниже:

Ваши контактные данные:

Шаг 1
Поздравляем! У вас есть промокод!

Тип желаемой лицензии:

Шаг 2
Team license
Enterprise license
** Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности
close form
Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Бесплатная лицензия PVS‑Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

close form
check circle
Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте папку
Spam/Junk и нажмите на письме кнопку "Не спам".
Так Вы не пропустите ответы от нашей команды.

>
>
>
Всегда ли в C# есть упаковка при конкат…

Всегда ли в C# есть упаковка при конкатенации со строкой и интерполяции?

03 Авг 2023

Разработчики на C# хорошо знакомы с термином "упаковка". Она может быть явной, а может быть незаметна. Например, к упаковке приводит сложение значимого типа со строкой. Или не приводит. Такая вот "упаковка Шрёдингера". В заметке попробуем разобраться с этой неопределённостью.

1060_NoteAboutBoxing_ru/image1.png

Как мы с этим столкнулись

Данная тема всплыла не случайно. Дело в том, что я участвую в разработке C# анализатора PVS-Studio. Одним из направлений его развития в 2023 году стали диагностические правила, ориентированные на проекты под Unity Engine. В частности, мы решили реализовать диагностики, указывающие на возможности оптимизации.

Начали мы с правила V4001. Оно определяет, какой код в проекте выполняется сравнительно часто, и указывает на случаи упаковки в нём. Упаковка является достаточно дорогой операцией по сравнению с обычной передачей по ссылке или значению, поэтому мы и решили реализовать функционал поиска мест её применения.

Одним из рассмотренных случаев была упаковка при конкатенации строки и значения:

string Foo(int a)
{
  return "The value is " + a;
}

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

Откуда вообще берётся упаковка при конкатенации?

Упаковка производится при преобразовании переменной значимого типа в переменную типа Object или в тип интерфейса, реализуемого этим значимым типом. Преобразование такого рода может быть явным и неявным. Явным преобразованием можно считать непосредственное приведение типа:

var boxedInt = (object)1;

Неявное преобразование производится в случаях, когда переменная значимого типа используется там, где ожидается либо ссылка типа Object, либо ссылка на реализуемый этим значимым типом интерфейс:

bool Foo(object obj, int number)
{
  return obj.Equals(number);
}

Метод Equals ожидает аргумент типа Object, поэтому значение number при передаче будет упаковано.

А что происходит при конкатенации? В некотором роде ответ может дать Visual Studio:

1060_NoteAboutBoxing_ru/image2.png

В качестве правого операнда оператор принимает Object, а значит, значение a будет упаковано. По крайней мере, так кажется.

Истина в IL-е

Конечно, в таких вопросах доверять подсказкам IDE "на слово" нельзя. Давайте глянем, во что превращается код, представленный выше:

.method private hidebysig static void  Foo(string str,
                                           int32 a) cil managed
{
  ....
  IL_0001:  ldarg.0
  IL_0002:  ldarg.1
  IL_0003:  box      [mscorlib]System.Int32
  IL_0008:  call     string [mscorlib]System.String::Concat(object,
                                                            object)
  IL_000d:  stloc.0
  IL_000e:  ret
}

Для простоты я слегка сократил полученный IL-код. Главное, что мы здесь можем увидеть, — инструкция box. Она и указывает на операцию упаковки значения переменной a. Также можно заметить, что вызываемый String.Concat принимает 2 ссылки типа Object, а не String и Object, как можно было подумать. В любом случае, факт наличия упаковки неоспорим.

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

Но как же так может быть? Ведь мы видели в IL-коде команду box! Неужели это не упаковка? Что ж, давайте ещё раз взглянем на результат компиляции:

.method private hidebysig static void  Foo(string str,
                                           int32 a) cil managed
{
  ....
  IL_0001:  ldarg.0
  IL_0002:  ldarga.s   a
  IL_0004:  call       instance string [mscorlib]System.Int32::ToString()
  IL_0009:  call       string [mscorlib]System.String::Concat(string,
                                                              string)
  IL_000e:  stloc.0
  IL_000f:  ret
}

Как я и сказал, никакой упаковки тут нет :).

Ладно-ладно, внимательные (да и не очень) читатели наверняка заметили, что IL-код в этих случаях значительно отличается. В предыдущем примере действительно была упаковка и вызов String.Concat(object, object). В этом же у числовой переменной вызывается метод ToString, после чего вполне логично используется метод для конкатенации 2 строк.

Однако важно отметить: исходный код для обоих примеров один и тот же.

В чём отличие?

Как нетрудно догадаться, отличие в алгоритме сборки. Дело в том, что начиная с некоторой версии, компилятор C# стал автоматически оптимизировать такие случаи конкатенации. Я довольно быстро заметил, что если код компилируется из-под Visual Studio 2019 или более новой версии, то никакой упаковки при конкатенации не будет. Затем я решил исследовать чуть глубже и поверхностно рассмотреть ситуацию с разными платформами.

С проектами под .NET Framework всё довольно просто. Если для сборки используется MSBuild от Visual Studio 2017 или более старой, то упаковка при конкатенации не оптимизируется. При этом версия целевой платформы не имеет значения (по крайней мере, выбор самой новой на данный момент версии никаких оптимизаций не принёс).

В .NET Core оптимизация присутствует примерно с версии 3.1. Опять же, обращу внимание, что совершенно не важно, какая версия TargetFramework выставлена для самого проекта. Всё зависит именно от используемой версии SDK.

Думаю, не будет сюрпризом и наличие рассмотренной оптимизации для .NET 5 (и более новых).

Оптимизации времени выполнения

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

Я протестировал это на проекте под .NET Framework. Увы, никаких оптимизаций я не увидел: если в получившемся IL-коде была упаковка, то и во время выполнения она действительно выполнялась (очень заметна разница в количестве аллокаций).

Если вас заинтересовала данная тема, и вы решите её поисследовать, то прошу написать о находках в комментариях :). А пока предлагаю рассмотреть ещё один интересный связанный вопрос.

Интерполяция

С упаковкой при конкатенации разобрались. А как дела обстоят с похожей операцией — интерполяцией? Ведь это практически то же самое — соединение разных кусочков в одну строку. По факту, конечно, всё совсем не так. В первую очередь стоит сказать, что здесь есть различия в зависимости от выбранной целевой платформы.

.NET Framework

Давайте взглянем на ещё один пример:

void Foo(string str, int num)
{
  _ = $"{str} {num}";
}

В этот раз без хитростей — говорю сразу, что компилирую этот код из Visual Studio 2022, не выполняя никаких противоестественных действий :). Давайте взглянем на результат:

.method private hidebysig instance void  Foo(string str,
                                             int32 num) cil managed
{
  ....
  IL_0001:  ldstr      "{0} {1}"
  IL_0006:  ldarg.1
  IL_0007:  ldarg.2
  IL_0008:  box        [mscorlib]System.Int32
  IL_000d:  call       string [mscorlib]System.String::Format(string,
                                                              object,
                                                              object)
  IL_0012:  pop
  IL_0013:  ret
}

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

Давайте попробуем сами вызвать ToString:

1060_NoteAboutBoxing_ru/image3.png

Встроенное в Visual Studio правило IDE0071 предлагает убрать "бесполезный" вызов ToString. Однако из результатов компиляции польза такого вызова очевидна:

.method private hidebysig instance void  Foo(string str,
                                             int32 num) cil managed
{
  ....
  IL_0001:  ldarg.1
  IL_0002:  ldstr      " "
  IL_0007:  ldarga.s   num
  IL_0009:  call       instance string [mscorlib]System.Int32::ToString()
  IL_000e:  call       string [mscorlib]System.String::Concat(string,
                                                              string,
                                                              string)
  IL_0013:  pop
  IL_0014:  ret
}

Больше нет никакой упаковки. Более того, тут даже нет вызова String.Format — код превратился в конкатенацию 3 строк.

.NET Core и .NET

Рассмотрим поведение на этих платформах на том же самом примере:

void Foo(string str, int num)
{
  _ = $"{str} {num}";
}

Здесь эксперименты показали, что наличие оптимизации зависит исключительно от целевой платформы проекта. Если проект ориентирован на .NET Core или .NET 5, то для представленного кода IL формируется точно так же, как и в случае с .NET Framework (то есть никаких оптимизаций нет, производится упаковка и вызов String.Format).

Если же проект ориентирован на .NET 6 и выше, то результат компиляции разительно отличается:

.method private hidebysig instance void  Foo(string str,
                                             int32 num) cil managed
{
  ....
  .locals init (valuetype DefaultInterpolatedStringHandler V_0)
  IL_0000:  nop
  IL_0001:  ldloca.s V_0
  IL_0003:  ldc.i4.1
  IL_0004:  ldc.i4.2
  IL_0005:  .... DefaultInterpolatedStringHandler::.ctor(int32, int32)
  IL_000a:  ldloca.s   V_0
  IL_000c:  ldarg.1
  IL_000d:  .... DefaultInterpolatedStringHandler::AppendFormatted(string)
  IL_0012:  nop
  IL_0013:  ldloca.s V_0
  IL_0015:  ldstr " "
  IL_001a:  .... DefaultInterpolatedStringHandler::AppendLiteral(string)
  IL_001f:  nop
  IL_0020:  ldloca.s   V_0
  IL_0022:  ldarg.2
  IL_0023:  .... DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
  IL_0028:  nop
  IL_0029:  ldloca.s   V_0
  IL_002b:  .... DefaultInterpolatedStringHandler::ToStringAndClear()
  IL_0030:  pop
  IL_0031:  ret
}

В угоду читаемости код был сильно сокращён. Мягко говоря, всё стало чуть сложнее простого вызова String.Format :). Вместо этого для формирования строки используется структура DefaultInterpolatedStringHandler. Исследование эффективности работы данного подхода выходит за рамки данной статьи, но кое-что тут явно бросается в глаза (если они не вытекли от такого количества IL-а, конечно).

Обратите внимание на вызов DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0). Скажу честно — я не имею представления о том, что такое "!!0", но наличие generic-параметра намекает, что никакой упаковки числа тут не будет.

.NET 6 рулит, в общем :).

Заключение

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

Интерполяция защищена от упаковки только в случае, если проект нацелен на .NET 6 и выше. В прочих ситуациях вызов ToString у элементов интерполяции может быть весьма полезен.

Благодарю вас за внимание. Напомню, что я участвую в разработке анализатора PVS-Studio, который позволяет искать в коде разные ошибки. Если вдруг захотите попробовать его в деле, то сделать это можно бесплатно здесь. Желаю удачи!

Популярные статьи по теме
Распространённые паттерны опечаток при программировании

Дата: 25 Авг 2023

Автор: Андрей Карпов

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

Дата: 09 Авг 2023

Автор: Никита Паневин

MassTransit — Open Source платформа распределённых приложений для .NET. В этой статье мы расскажем о проблемных местах в коде проекта. С поиском таких мест нам поможет статический анализатор...
Ryujinx: повторная проверка эмулятора Nintendo Switch с помощью PVS-Studio

Дата: 21 Июл 2023

Автор: Глеб Асламов

Популярность Nintendo Switch не угасает, эксклюзивные игры получают награды, и желание в них поиграть только растет. Однако возможность опробовать портативную приставку есть не у каждого. Решает эту.…
Изучаем подозрительные места в коде AWS SDK для .NET

Дата: 04 Июл 2023

Автор: Сергей Васильев

Сегодня под нашим скальпелем оказался проект AWS SDK для .NET. Мы посмотрим на подозрительные места из исходного кода, разберёмся, что в них происходит, и попробуем воспроизвести некоторые проблемы...
Обзор Top-3 Open Source игр на C# и ошибок в их коде

Дата: 30 Июн 2023

Автор: Андрей Москалёв

В мире много хороших игр, но совсем немногие открывают исходный код. В этой статье мы познакомимся, на мой взгляд, с тремя лучшими Open Source играми на C# и разберём наиболее интересные ошибки...


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

Следующие комментарии next comments
close comment form