Всегда ли в C# есть упаковка при конкатенации со строкой и интерполяции?
Разработчики на C# хорошо знакомы с термином «упаковка». Она может быть явной, а может быть незаметна. Например, к упаковке приводит сложение значимого типа со строкой. Или не приводит. Такая вот «упаковка Шрёдингера». В заметке попробуем разобраться с этой неопределённостью.
Как мы с этим столкнулись
Данная тема всплыла не случайно. Дело в том, что я участвую в разработке 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:
В качестве правого операнда оператор принимает 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:
Встроенное в 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(!!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
.NET 6 рулит, в общем :).
Заключение
В общем, если мы пользуемся старыми версиями компилятора, то упаковка при конкатенации действительно есть, а значит и есть смысл в вызовах ToString. В новых же версиях никакой упаковки так и так не будет (надеюсь, никто не станет мучить подобными вопросами кандидатов на собеседованиях).
Интерполяция защищена от упаковки только в случае, если проект нацелен на .NET 6 и выше. В прочих ситуациях вызов ToString у элементов интерполяции может быть весьма полезен.
Благодарю вас за внимание. Напомню, что я участвую в разработке анализатора PVS-Studio, который позволяет искать в коде разные ошибки. Если вдруг захотите попробовать его в деле, то сделать это можно бесплатно здесь. Желаю удачи!
Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Nikita Lipilin. Does C# always have boxing with string concatenation and interpolation?.