Так ли прост строковый оператор +

    Введение


    Строковый тип данных является одним из фундаментальных типов, наряду с числовыми (int, long, double) и логическим (bool). Тяжело себе представить хоть, сколько либо полезную программу, не использующую данный тип.

    На платформе .NET строковый тип представлен в виде неизменяемого класса String. Кроме того, он является сильно интегрированным в общеязыковую среду CLR, а так же имеет поддержку со стороны компилятора языка C#.

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

    Спецификация языка о строковом операторе +


    Спецификация языка C# предоставляет три перегрузки оператора + для строк:

    string operator + (string x, string y)
    string operator + (string x, object y)
    string operator + (object x, string y)
    

    Если один из операндов объединения строк есть null, то подставляется пустая строка. Иначе любой аргумент, не являющийся строкой, приводится к представлению в виде строки с помощью вызова виртуального метода ToString. Если метод ToString возвращает null, подставляется пустая строка. Следует сказать, что согласно спецификации данная операция никогда не должна возвращать значение null.

    Описание оператора выглядит достаточно понятно, однако если мы взглянем на реализацию класса String то найдем явное определение лишь двух операторов == и !=. Возникает резонный вопрос, что происходит за кулисами конкатенации строк? Каким образом компилятор обрабатывает строковый оператор +?

    Ответ на этот вопрос оказался не таким уж сложным. Необходимо присмотреться повнимательнее к статическому методу String.Concat. Метод String.Concat — объединяет один или несколько экземпляров класса String или представления в виде String значений одного или нескольких экземпляров Object. Имеются следующие перегрузки данного метода:

    public static String Concat(String str0, String str1)
    public static String Concat(String str0, String str1, String str2)
    public static String Concat(String str0, String str1, String str2, String str3)
    public static String Concat(params String[] values)
    public static String Concat(IEnumerable<String> values)
    
    public static String Concat(Object arg0)
    public static String Concat(Object arg0, Object arg1)
    public static String Concat(Object arg0, Object arg1, Object arg2)
    public static String Concat(Object arg0, Object arg1, Object arg2, Object arg3, __arglist)	
    
    public static String Concat<T>(IEnumerable<T> values)
    

    Подробнее


    Пусть у нас есть следующее выражение s = a + b, где a и b — строки. Компилятор преобразует его в вызов статического метода Concat, то есть в

    s = string.Concat(a, b)
    

    Операция конкатенации строк, как и любая другая операция сложения в языке C# является лево-ассоциативной.

    С двумя строками все понятно, но что если строк больше? Выражение s = a + b + c учитывая лево-ассоциативность операции могло бы быть заменено на

    s = string.Concat(string.Concat(a, b), c)
    

    однако, учитывая наличие перегрузки, принимающей три аргумента оно будет преобразовано в

    s = string.Concat(a, b, c)
    

    Аналогично дела обстоят с конкатенацией четырех строк. Для конкатенации 5 и более строк имеем перегрузку string.Concat(params string[]), так что необходимо учитывать накладные расходы, связанные с выделением памяти под массив.

    Следует так же сказать, что операция конкатенации строк является полностью ассоциативной: не имеет никакого значения, в каком порядке мы конкатенируем строки, поэтому выражение s = a + (b + c) не смотря на явное указание приоритета выполнения конкатенации, обрабатывается как

    s = (a + b) + c = string.Concat(a, b, c) 
    

    вместо ожидаемого

    s = string.Concat(a, string.Concat(b, c))
    

    Таким образом, подытоживая сказанное выше: операция конкатенации строк всегда представляется слева направо, и использует вызов статического метода String.Concat.

    Оптимизации компилятора для литеральных строк


    Компилятор языка C# имеет оптимизации связанные с литеральными строками. Так, например, выражение s = "a" + "b" + c, учитывая лево-ассоциативность оператора + эквивалентно s = ("a" + "b") + c преобразуется в

    s = string.Concat("ab", c)
    

    Выражение s = c + "a" + "b" несмотря на лево-ассоциативность операции конкатенации (s = (c + "a") + "b") преобразуется в

    s = string.Concat(c, "ab")
    

    В общем, неважно в каком месте находятся литералы, компилятор конкатенирует всё что может, а уже потом пытается выбрать соответствующую перегрузку метода Concat. Выражение s = a + "b" + "c" + d преобразуется в

    s = string.Concat(a, "bc", d)
    

    Следует так же сказать об оптимизациях связанных с пустой и null строкой. Компилятор знает, что добавление пустой строки не влияет на результат конкатенации, поэтому выражение s = a + "" + b преобразуется в

    s = string.Concat(a, b), 
    

    вместо ожидаемого

    s = string.Concat (a, "", b)
    

    Аналогично для const строки, значение которой есть null, имеем:

    const string nullStr = null;
    s = a + nullStr + b;
    

    преобразуется в

    s = string.Concat(a, b)
    

    Выражение s = a + nullStr преобразуется в s = a ?? "", если a — строка, и вызов метода string.Concat(a), если a – не строка, например, s = 17 + nullStr, преобразуется в s = string.Concat(17).

    Интересная особенность, связанная с оптимизацией обработки литералов и лево-ассоциативностью строкового оператора +.

    Рассмотрим выражение:

    var s1 = 17 + 17 + "abc";
    

    учитывая лево-ассоциативность, оно эквивалентно

    var s1 = (17 + 17) + "abc"; // вызов метода string.Concat(34, "abc")
    

    в результате чего на этапе компиляции произойдет сложение чисел, так что результатом будет 34abc.

    С другой стороны выражение

    var s2 = "abc" + 17 + 17;
    

    эквивалентно

    var s2 = ("abc" + 17) + 17; // вызов метода string.Concat("abc", 17, 17)
    

    в результате чего получим abc1717.

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

    String.Concat VS StringBuilder.Append


    Следует сказать пару слов и об этом сравнении. Рассмотрим следующий код:

    string name = "Timur";
    string surname = "Guev";
    string patronymic = "Ahsarbecovich";
    string fio = surname + name + patronymic;
    

    Его можно заменить на код, используя StringBuilder:

    var sb = new StringBuilder();
    sb.Append(surname);
    sb.Append(name);
    sb.Append(patronymic);
    string fio = sb.ToString();
    

    Но едва ли мы получим в данной ситуации преимущества от использования StringBuilder. Помимо того, что код стал менее читабельным, он стал еще и менее эффективным, поскольку реализация метода Concat вычисляет длину результирующей строки и выделяет память только один раз, в отличие от StringBuilder-a который ничего не знает о длине результирующей строки.

    Реализация метода Concat для 3 строк:

    public static string Concat(string str0, string str1, string str2)
     {
       if (str0 == null && str1 == null && str2 == null)
         return string.Empty;
       if (str0 == null)
         str0 = string.Empty;
       if (str1 == null)
         str1 = string.Empty;
       if (str2 == null)
         str2 = string.Empty;
       string dest = string.FastAllocateString(str0.Length + str1.Length + str2.Length); // выделяем память для строки
       string.FillStringChecked(dest, 0, str0); /
       string.FillStringChecked(dest, str0.Length, str1);
       string.FillStringChecked(dest, str0.Length + str1.Length, str2);
       return dest;
     }
    

    Оператор + в Java


    Пару слов о строковом операторе + в Java. Хотя я и не программирую на Java, интересно все же знать, как дела обстоят там. Компилятор языка Java оптимизирует оператор +, так что он использует класс StringBuilder и вызов метода append.

    Предыдущий код преобразуется в

    String fio = new StringBuilder(String.valueOf(surname)).append(name).append(patronymic).ToString()
    

    Стоит так же сказать, что от такой оптимизации в C# отказались намеренно, у Эрика Липперта есть пост на эту тему. Дело в том, что такая оптимизация не является оптимизацией как таковой, она является переписыванием кода. Плюс к этому создатели языка C# считают, что разработчики должны знать особенности работы с классом String и в случае необходимости перейдут на использование StringBuilder.

    Кстати именно Эрик Липперт занимался оптимизациями компилятора C#, связанными с конкатенацией строк.

    Заключение


    Возможно, на первый взгляд может показаться странным, что класс String не определяет оператор +, пока мы не подумаем о возможностях оптимизации компилятора, связанных с видимостью большего фрагмента кода. Например, если бы оператор + был определен в классе String, то выражение s = a + b + c + d приводило бы к созданию двух промежуточных строк, единственный вызов метода string.Concat(a, b, c, d) позволяет выполнить объединение более эффективно.

    Средняя зарплата в IT

    113 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 5 444 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 10

      0
      Я когда-то, может даже на хабре, прочитал, что конкатенация трех строк преобразуется в две конкатенации и с тех пор использовал Concat вручную для 3-4 строк. А еще не задумывался о сложении 7-10 строк, где оказывается лучше было вместо Concat использовать StringBuilder. Спасибо.
        0
        Я когда-то, может даже на хабре, прочитал, что конкатенация трех строк преобразуется в две конкатенации и с тех пор использовал Concat вручную для 3-4 строк.
        Тоже когда то думал делать так, но после написания первого кода с таким подходом понял, что выигрыш в производительности минимален, а вот читабельность падает в разы.
        Добавление: что с тегами? Они у меня не работают.
        +2
        На самом деле если конкатенация происходит не в цикле, то даже при 7-10 строк можно использовать String.Concat.
        Его реализация, так же как и для 3 строк подсчитывает суммарную длинну строки и единожды выделяет для нее память.
        Тот же StringBuilder внутри себя содержит массив char[] (точнее он является связным списком), так что наклодные расходы по памяти будут и там, но код теряет читабельност при этом.
          –1
          На самом деле на практике даже если конкатенация в цикле происходит как правило StringBuilder использовать не стоит. Потому, как правило это указывает на антипаттерн «стрингли тайпед девелопмент». И этой конкатенации нужно было с самого начала избежать.

          Это встречается например когда люди сериализуют обьекты руками вместо того, чтобы использовать библиотеки сериализации или формируют верстку руками, вместо того, чтобы использовать шаблоны верстки.

          Если я вижу StriingBuilder в коде — обычно для меня это сигнал, что работа со строками используется не по назначению. И люди вместо того, чтобы от нее избавиться, впихнули стринг билдер. А если речь о какой то элементарной конкатенации строк — то нужен не он, а Concat или String.Format или String.Join. Это читабельней и как правило не может быть дырой производительности ни при каких условиях.

          Количество реальных ситуаций, когда использование StringBuilder — правильное решение — исчезающе мало. Самописные библиотеки сериализации возможно. На серьезных проектах — это как правило smell архитектурных проблем.
          0
          На самом деле если конкатенация происходит не в цикле, то даже при 7-10 строк можно использовать String.Concat.
          Его реализация, так же как и для 3 строк подсчитывает суммарную длинну строки и единожды выделяет для нее память.
          Тот же StringBuilder внутри себя содержит массив char[] (точнее он является связным списком), так что наклодные расходы по памяти будут и там, но код теряет читабельност при этом.

          Упс… дважды отправил
          0
          В Java, насколько я знаю, если «foo» + «bar» указаны как литералы, то они становятся одной строкой на этапе компиляции, а вот в runtime уже никаких оптимизаций нет. И если нам нужно складывать много строк в runtime, то пользуются StringBuilder'ом
            0
            Да литералы конкатенируются на этапе компиляции, а не литералы используют StringBuilder.append.
              0
              Интересно дела обстоят. Есть вопрос по поводу «реализации конкатенации для трех строк» на с#. Это ведь реальный статический метод из класса String для конкатенации трех строк?

              P.s за сравнение с java спасибо, но было бы также очень интересно узнать, как обстоят дела в javascript. Имхо, там конкатенация вопрос куда более частый, а следовательно и детали чрезвычайно важны.
                0
                Да это реальный статический метод для конкатенации 2, 3 и 4 строк. Его можно вызвать на прямую, просто код станет менее читабельным, нежели используя оператор + который в него и разворачивается.

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

              Самое читаемое