Pull to refresh

Прав ли R#: call to .ToString() is redundant?

Reading time 9 min
Views 3.5K
Этот пост публикуется по просьбе хабраюзера mstyura, у которого не хватает кармы для публикации. Если вам понравилась статья, то благодарите автора и помогите ему с кармой.

Хочу поделиться с Хабросообществом результатом своего минииcследования на тему упаковки\распаковки значимых типов. На написание данного топика меня сподвигли две вещи: книга Рихтера «CLR via c#» и собственно R#. Последний на мой взгляд давал «нечестные» замечания моему коду.

В чем же дело?


Дело в том, что я написал довольно стандартный код, наподобие вот этого
string str = "habrahabr";
int val = 0;
var resultString = str + val.ToString();

* This source code was highlighted with Source Code Highlighter.

Решарперу не понравился мой явный вызов метода ToString() у значимой переменной val, а мне естественно не понравилось, что он мне указывает что делать, когда я точно знаю, что в данном случае лучше поступить так, как написал я. Давайте разберемся кто прав. Для начала я предлагаю разобраться, какие операции будут происходить при выполнении кода, предложенного R#, т.е вот этого
string str = "habrahabr";
int val = 0;
var resultString = str + val;

* This source code was highlighted with Source Code Highlighter.

В последней строчке мы видим операцию сложения двух переменных различных типов, результатом которой будет строка. Так как переменные различных типов, то вызовется следующая версия метода string System.String.Concat(object, object). Т.е. фактически код программы будет следующим
string str = "habrahabr";
int val = 0;
var resultString = System.String.Concat(str, val);

* This source code was highlighted with Source Code Highlighter.

При передаче параметров str(строка — ссылочный тип) и val(целое число — значимый тип) методу Concat для второго параметра будет выполнена операция упаковки, т.к мы пытаемся передать в метод значимый тип int, а он ожидает ссылочный object.

Что такое упаковка?


За точным определением я обратился к книге Рихтера «CLR via c#». Итак:
Упаковкой(boxing) называется преобразование значимого типа в ссылочный. При упаковке экземпляра значимого типа происходит следующее.
1. В управляемой куче выделяется память. Ее объем определяется длиной значимого типа и двумя дополнительными членами, необходимыми для всех объектов в управляемой куче, — указателем на объект-тип и индексом SyncBlockIndex.
2. Поля значимого типа копируются в память, выделенную только что в куче.
3. Возвращается адрес объекта. Этот адрес является ссылкой на объект; значимый тип превратился в ссылочный.

А если все-таки вызвать ToString()


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

Во что компилируется?


Без вызова ToString()
  1.  .locals init ([0] string str,[1] int32 val,[2] string resultString)
  2.  IL_0000: nop
  3.  IL_0001: ldstr   "habrahabr"
  4.  IL_0006: stloc.0
  5.  IL_0007: ldc.i4.0
  6.  IL_0008: stloc.1
  7.  IL_0009: ldloc.0
  8.  IL_000a: ldloc.1
  9.  IL_000b: box    [mscorlib]System.Int32
  10.  IL_0010: call    string [mscorlib]System.String::Concat(object, object)
  11.  IL_0015: stloc.2
* This source code was highlighted with Source Code Highlighter.

С вызовом ToString()
  1.  .locals init ([0] string str, [1] int32 val, [2] string resultString)
  2.  IL_0000: nop
  3.  IL_0001: ldstr   "habrahabr"
  4.  IL_0006: stloc.0
  5.  IL_0007: ldc.i4.0
  6.  IL_0008: stloc.1
  7.  IL_0009: ldloc.0
  8.  IL_000a: ldloca.s  val
  9.  IL_000c: call    instance string [mscorlib]System.Int32::ToString()
  10.  IL_0011: call    string [mscorlib]System.String::Concat(string, string)
  11.  IL_0016: stloc.2
* This source code was highlighted with Source Code Highlighter.

Главное различие в приведенных кодах IL инструкция box, производящая операцию упаковки, противоположная ей команда распаковки соответсвует инстукции unbox, я ее здесь не рассматриваю.

Влияние на производительность


Очевидно, что судить о производительности приведенного выше кода сложно, т.к. единичная операция упаковки происходить достаточно быстро. Напишем следующий цикл
for (int i = 0; i < 10000000; i++)
{
  var s = "habrahabr" + i;
}

* This source code was highlighted with Source Code Highlighter.

Для замера времени выполнения используем класс Stopwatch из пространства имен System.Diagnostics, т.к. он дает гораздо более точный результат, чем использование DateTime. Например, на моем машине вызов двух подряд DateTime.Now дает разницу 00:00:00.0010000, а запуск и сразу остановка Stopwatch — 00:00:00.0000015. Разница винда невооруженным глазом.
Итоговый код, которым мы будем тестировать операцию упаковки будет выглядеть следующим образом
namespace BoxingTraining
{
  using System;
  using System.Diagnostics;
  public class Program
  {
    private static void Main()
    {
      var time = Stopwatch.StartNew();
      for (int i = 0; i < 10000000; i++)
      {
        var s = "habrahabr" + i;
      }
      Console.WriteLine(time.Elapsed.ToString());
    }
  }
}

* This source code was highlighted with Source Code Highlighter.

Ниже приведена таблица с результатами запуска приведенного выше кода при упаковке значения int(4 байта)
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0324421 0,0314838 0,0009583 3,043788
1 000 000 0,3329810 0,2927090 0,0402720 13,75837
10 000 000 3,5344330 3,2425843 0,2918487 9,000497
100 000 000 35,9022937 35,4208982 0,4813955 1,359072

Как видно результаты хоть и не в пользу упаковки, но не такие ужасные, как могло показаться сразу. Далее можно предположить, что размер типа(значение занимаемой памяти) тоже может влиять на результат, и дальнейшим шагом станет упаковка одного значения типа char, byte и какой-нибудь тяжеловесной самописной структуры.
Код для тестирования упаковки любых переменных значимого типа.
namespace boxingTraining
{
  using System;
  using System.Diagnostics;
  public class Program
  {
    private static void Main()
    {
      var time = Stopwatch.StartNew();
      for (int i = 0; i < 1000000; i++)
      {
        var s = "habrahabr" + Переменная значимого типа;
      }
      Console.WriteLine(time.Elapsed.ToString());
    }
  }
}

* This source code was highlighted with Source Code Highlighter.

Таблица получившихся результатов для char(2 байта)
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0120120 0,0080280 0,003984 49,62631
1 000 000 0,0925545 0,0738690 0,0186855 25,29546
10 000 000 0,8949694 0,7298598 0,1651096 22,6221
100 000 000 9,1908556 6,9977169 2,1931387 31,34077

Таблица результатов для byte(1 байт)
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0264363 0,0242211 0,0022152 9,145745
1 000 000 0,2600672 0,2304188 0,0296484 12,86718
10 000 000 2,5563460 2,2713021 0,2850439 12,5498
100 000 000 25,1847944 22,3063352 2,8784592 12,90422

Небольшое лирическое отступление. Когда я писал данный топик и дошел до данного момента, хабраюзер Aldanko, с которым я советовался при написании этой статьи, настоятельно порекомендовал провести тестирование для большего количества стандартных типов, чтобы посмотреть, как они себя ведут в данной ситуации и исследовать влияние внутреннего устройства типа на операцию упаковки.
Проведем тестирование для следующих типов System.Int16(2 байта), System.Int64(8 байт), System.Single(4 байта), System.Double(8 байт) и System.Decimal(16 байт).
Для System.Int16
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0268269 0,0252753 0,001552 6,1388
1 000 000 0,2508171 0,2283134 0,022504 9,856496
10 000 000 2,5840173 2,2771103 0,306907 13,47792
100 000 000 25,5575014 22,6322024 2,925299 12,92538

Для System.Int64
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0313252 0,0261576 0,005168 19,75564
1 000 000 0,2730405 0,2520212 0,021019 8,34029
10 000 000 2,7573422 2.3089744 0,448368 19,41848
100 000 000 26,6876964 23.3565123 3,331184 14,26234

Для System.Single
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0488271 0,0435174 0,00531 12,20133
1 000 000 0,4585808 0,4277303 0,030851 7,212606
10 000 000 4,5009957 4,2313242 0,269671 6,373218
100 000 000 44,9906154 42,5702807 2,420335 5,685503

Для System.Double
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0477293 0,0454006 0,002329 5,129227
1 000 000 0,4774911 0,4507611 0,02673 5,92997
10 000 000 4,7426156 4,5235923 0,219023 4,8418
100 000 000 47,4816881 44.3383082 3,14338 7,089535

Для System.Decimal
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0.0342277 0.0302381 0,00399 13,19395
1 000 000 0,3082451 0,2852763 0,022969 8,051422
10 000 000 3,0517615 2,8142709 0,237491 8,438797
100 000 000 30,7824241 27.8104480 2,971976 10,68655

Как видно вызов ToString() всегда быстрее. Он впринципе не может быть медленне, т.к. если его не вызывать, то он все равно вызовется, но перед этим еще произойдет упаковка объекта значимого типа.

Производительность пользовательских структур


Наиболее расространненые стандартные типы я уже протестировал. Настало время написать свою структуру. По совету того же хабраюзера Aldanko  буду писать структуру содержащую 1600 полей типа byte(1 байт) и 100 полей типа Decimal(16 байт). Первая структура выглядит следующим образом
public struct DecimalStruct
{
  public decimal Field1;
  public decimal Field2;
  public decimal Field3;
  public decimal Field4;
  ...
  public decimal Field97;
  public decimal Field98;
  public decimal Field99;
  public decimal Field100;
  public override string ToString()
  {
    return "DecimalStruct";
  }
}

* This source code was highlighted with Source Code Highlighter.

Полную версию кода можно скачать
Структура с 1600 полями типа byte
public struct ByteStruct
{
  public byte Field1;
  public byte Field2;
  public byte Field3;
  public byte Field4;
  ...
  public byte Field1597;
  public byte Field1598;
  public byte Field1599;
  public byte Field1600;
  public override string ToString()
  {
    return "ByteStruct";
  }
}

* This source code was highlighted with Source Code Highlighter.

Полную версию кода можно скачать
Соответсвенно код которым будем тестировать операцию упаковки будет следующий
namespace boxingTraining
{
  using System;
  using System.Diagnostics;
  public class Program
  {
    private static void Main()
    {
      var myStruct = new ByteStruct();//или new DecimalStruct()
      var time = Stopwatch.StartNew();
      
      for (int i = 0; i < 100000; i++)
      {
        var s = "habrahabr" + myStruct;//или myStruct.ToString()
      }
      Console.WriteLine(time.Elapsed.ToString());
    }
  }
}

* This source code was highlighted with Source Code Highlighter.

Цель данных махинаций посмотреть, как повлияет внутреннее устройство структуры на время операции упаковки, а именно на копирование внутренних данных в управляемую кучу.
Итак итоги. Для ByteStruct
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0553811 0,0072760 0,048105 661,1476
1 000 000 0,5218077 0,0591120 0,462696 782,7441
10 000 000 5,2083878 0,5520037 4,656384 843,5422
100 000 000 61,8142750 5,5449448 56,26933 1014,786

Итог для DecimalString
Количество итераций в цикле Время, с. Разница, c. Выйгрыш, %
Без ToString() С ToString()
100 000 0,0815664 0,0068541 0,074712 1090,038
1 000 000 0,8208648 0,0638818 0,756983 1184,974
10 000 000 6,9349283 0,6249309 6,309997 1009,711
100 000 000 54,7189205 6,8453349 47,87359 699,3608

Как видно из приведенных выше тестов на объемистых структурах вызов ToString дает выигрыш до 10 раз. Правда чтобы это было хорошо заметно, нужно повторить операцию упаковки не один раз.

Выводы


Какой можно сделать вывод из данного миниисследования? По крайне мере для меня он очевиден. Для ссылочных типов вызов ToString() никой выгоды не даст и его употребление в оперциях со строками будет избыточно, а вот для значимых типов вызов ToString() необходим. При его вызове мы избегаем достаточно ресурсоемкую операцию — упаковку, которая в опеределенных случаях может значительно ухудшить производительность кода. Ну а R# оказался не прав, ругаясь что, вызов ToString() у значимого типа при конкатенации строк избыточен. Его то можно и не производить, но можно поплатиться производительностью.

Progg it
Tags:
Hubs:
+29
Comments 30
Comments Comments 30

Articles