Разработчики на 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, который позволяет искать в коде разные ошибки. Если вдруг захотите попробовать его в деле, то сделать это можно бесплатно здесь. Желаю удачи!

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Nikita Lipilin. Does C# always have boxing with string concatenation and interpolation?.

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


  1. kuda78
    03.08.2023 10:47

    Интерполяция строк и C#10: рекомендую перечитать хорошую статью https://habr.com/ru/articles/590069/ из которой можно выяснить, что в некоторых случаях даже конкатенация строк может не выполнятся.


  1. ValeryIvanov
    03.08.2023 10:47

    Удивляет то, что интерполяция строки иногда компилируется в вызов String.Format. Почему так происходит, если конкатенация строк была бы куда выгоднее?


    1. ksbes
      03.08.2023 10:47

      "Простая конкатенация" может быть сильно хуже форматирования в сложных случаях (ну там вставить тысячу полей в многомегабайтный текст). А откуда компилятору заранее знать насколько сложная там разбивка строки? Нет, он конечно может провести некоторый анализ - и это было сделано в процессе развития языка, но в старых версиях остался более "надёжный", но менее оптимальный подход.


      1. ValeryIvanov
        03.08.2023 10:47

        Либо статья вводит в заблуждение, либо я делаю что-то не так, но у меня Visual Studio 2022 выдаёт совершенно другой IL код для _ = $"{str} {num}".

        IL код большой и его я закинул под спойлер(ибо он слишком большой), но вот как бы это выглядело, если перевести обратно в C#:

        [NullableContext(1)]
        [CompilerGenerated]
        internal static void <<Main>$>g__Foo|0_0(string str, int num)
        {
        	DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(1, 2);
        	defaultInterpolatedStringHandler.AppendFormatted(str);
        	defaultInterpolatedStringHandler.AppendLiteral(" ");
        	defaultInterpolatedStringHandler.AppendFormatted<int>(num);
        	defaultInterpolatedStringHandler.ToStringAndClear();
        }
        
        IL код
        // Token: 0x06000007 RID: 7 RVA: 0x000020A0 File Offset: 0x000002A0
        .method assembly hidebysig static 
        	void '<<Main>$>g__Foo|0_0' (
        		string str,
        		int32 num
        	) cil managed 
        {
        	.custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
        		01 00 01 00 00
        	)
        	.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        		01 00 00 00
        	)
        	// Header Size: 12 bytes
        	// Code Size: 50 (0x32) bytes
        	// LocalVarSig Token: 0x11000001 RID: 1
        	.maxstack 3
        	.locals init (
        		[0] valuetype [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler
        	)
        
        	/* (2,1)-(2,2) C:\Users\Professional\Projects\CSharp\test_cs\test_cs\Program.cs */
        	/* 0x000002AC 00           */ IL_0000: nop
        	/* (3,5)-(3,24) C:\Users\Professional\Projects\CSharp\test_cs\test_cs\Program.cs */
        	/* 0x000002AD 1200         */ IL_0001: ldloca.s  V_0
        	/* 0x000002AF 17           */ IL_0003: ldc.i4.1
        	/* 0x000002B0 18           */ IL_0004: ldc.i4.2
        	/* 0x000002B1 280F00000A   */ IL_0005: call      instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32, int32)
        	/* 0x000002B6 1200         */ IL_000A: ldloca.s  V_0
        	/* 0x000002B8 02           */ IL_000C: ldarg.0
        	/* 0x000002B9 281000000A   */ IL_000D: call      instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted(string)
        	/* 0x000002BE 00           */ IL_0012: nop
        	/* 0x000002BF 1200         */ IL_0013: ldloca.s  V_0
        	/* 0x000002C1 7201000070   */ IL_0015: ldstr     " "
        	/* 0x000002C6 281100000A   */ IL_001A: call      instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendLiteral(string)
        	/* 0x000002CB 00           */ IL_001F: nop
        	/* 0x000002CC 1200         */ IL_0020: ldloca.s  V_0
        	/* 0x000002CE 03           */ IL_0022: ldarg.1
        	/* 0x000002CF 280100002B   */ IL_0023: call      instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
        	/* 0x000002D4 00           */ IL_0028: nop
        	/* 0x000002D5 1200         */ IL_0029: ldloca.s  V_0
        	/* 0x000002D7 281300000A   */ IL_002B: call      instance string [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::ToStringAndClear()
        	/* 0x000002DC 26           */ IL_0030: pop
        	/* (4,1)-(4,2) C:\Users\Professional\Projects\CSharp\test_cs\test_cs\Program.cs */
        	/* 0x000002DD 2A           */ IL_0031: ret
        } // end of method Program::'<<Main>$>g__Foo|0_0'
        


        1. Firensis Автор
          03.08.2023 10:47

          В статье описано, что это зависит от того, подо что вы компилируете код. У вас net6?


          1. ValeryIvanov
            03.08.2023 10:47
            +1

            У вас net6?

            Да, я компилировал под net6, а в статье описывается как это всё компилируется под net5. А в заключении ещё и описано то, какой код будет сгенерирован под net6
            Не знаю, как так вышло, что я всё это проглядел. Прочитать статью внимательнее я времени не нашёл, но зато проверить всё самому — это запросто. Дурак я, в общем.


            1. Firensis Автор
              03.08.2023 10:47

              Ничего страшного, главное, что разобрались)


  1. Timofeuz
    03.08.2023 10:47

    Но ToString() ведь все равно приведет к аллокации, возможно даже больше размером.


    1. Firensis Автор
      03.08.2023 10:47

      Тут штука вот в чëм: ToString в любом случае будет вызван. Либо у исходной переменной, либо у ссылки, полученной после упаковки. В статье же говорится о том, как упаковки избежать, имея исключительно вызов ToString.