Пользователь
0,0
рейтинг
17 января 2013 в 14:23

Разработка → Строки в C# и .NET перевод

C#*, .NET*
image
От переводчика: Джон Скит написал несколько статей о строках, и данная статья является первой, которую я решил перевести. Дальше планирую перевести статью о конкатенации строк, а потом — о Юникоде в .NET.

Тип System.StringC# имеющий алиас string) является одним из наиболее часто используемых и важных типов в .NET, и вместе с тем одним из самых недопонимаемых. Эта статья описывает основы данного типа и развенчивает сложившиеся вокруг него мифы и непонимания.

Так что же такое string


Строка в .NET (далее — string, я не буду использовать полное имя System.String каждый раз) является последовательностью символов. Каждый символ является символом Юникода в диапазоне от U+0000 до U+FFFF (будет рассмотрено далее). Строковый тип имеет следующие характеристики:

Строка является ссылочным типом

Существует распространённое заблуждение о том, что строка является значимым типом. Это заблуждение истекает из свойства неизменяемости строки (см. следующий пункт), так как для неискушенного программиста неизменяемость часто по поведению кажется схожей со значимыми типами. Тем не менее, string — ссылочный тип, со всеми присущими ссылочным типам характеристиками. Я более детально расписал о различиях между ссылочными и значимыми типами в своих статьях «Parameter passing in C#» и «Memory in .NET — what goes where».

Строка является неизменяемой

Никак невозможно изменить содержимое созданной строки, по крайней мере в безопасном (safe) коде и без рефлексии. Поэтому вы при изменении строк изменяете не сами строки, а значения переменных, указывающих на строки. Например, код s = s.Replace ("foo", "bar"); не изменяет содержимое строки s, которое было до вызова метода Replace — он просто переназначает переменную s на новообразованную строку, которая является копией старой за исключением всех подстрок «foo», заменённых на «bar».

Строка может содержать значение null

В языке C строки являются последовательностями символов, оканчивающимися символом '\0', также называемым «nul» или «null». Я называю его «null», так как именно такое название имеет символ '\0' в таблице символов Юникода. Не путайте символ «null» с ключевым словом null в C# — тип System.Char является значимым, а потому не может принимать значение null. В .NET строки могут содержать символ «null» в любом месте и работать с ним без каких-либо проблем. Тем не менее, некоторые классы (к примеру, в Windows Forms) могут расценивать «null»-символ в строке как признак конца строки и не учитывать всё содержимое строки после этого символа, поэтому использование «null»-символов таки может стать проблемой.

Строка переопределяет оператор равенства ==

При вызове оператора == для определения равенства двух строк происходит вызов метода Equals, который сравнивает именно содержимое строк, а не равенство ссылок. К примеру, выражение "hello".Substring(0, 4)=="hell" возвратит true, хотя ссылки на строки по обеих сторонах оператора равенства разные (две ссылки ссылаются на два разных строковых экземпляра, которые, при этом, содержат одинаковые значения). Вместе с тем необходимо помнить, что равенство значений, а не ссылок происходит только тогда, когда оба операнда на момент компиляции являются строго строковым типом — оператор равенства не поддерживает полиморфизм. Поэтому если хотя бы один из сравниваемых операндов будет иметь тип object, к примеру (хотя внутренне и будет оставаться строкой), то будет выполнено сравнение ссылок, а не содержимого строк.

Интернирование


В .NET существует понятие «пула интернирования» (intern pool). По своей сути это всего лишь набор строк, но он обеспечивает то, что когда вы в разных местах программы используете разные строки с одним и тем же содержимым, то это содержимое будет храниться лишь один раз, а не создаваться каждый раз по-новому. Вероятно, пул интернирования зависит от конкретного языка, однако он определённо существует в C# и VB.NET, и я бы был очень удивлён, увидев язык на платформе .NET, не использующий пул интернирования; в MSIL пул интернирования очень просто использовать, гораздо проще, нежели не использовать. Наряду с автоматическим интернированием строковых литералов, строки можно интернировать вручную при помощи метода Intern, а также проверять, является ли та или иная строка уже интернированной при помощи метода IsInterned. Метод IsInterned не интуитивен, так как вы ожидаете, что он возвратит Boolean, а вот и нет — если текущая строка уже существует в пуле интернирования, то метод возвратит ссылку на неё, а если не существует, то null. Подобно ему, метод Intern возвращает ссылку на интернированную строку, причём вне зависимости от того, была ли текущая строка в пуле интернирования до вызова метода, или же она была туда занесена вместе с вызовом метода, или же пул интернирования содержит копию текущей строки.

Литералы


Литерал — это, грубо говоря, «захардкодженное» в коде значение строки. Есть два типа строковых литералов в C# — стандартные (regular) и дословные (verbatim). Стандартные литералы в C# схожи с таковыми в большинстве языков программирования — они обрамляются в двойные кавычки ("), а также могут содержать специальные символы (собственно двойные кавычки ("), обратный слеш (\), перенос строки (carriage return — CR), подача строки (line feed — LF) и некоторые другие), требующие экранирования. Дословные литералы позволяют почти то же самое, что и стандартные, однако дословный литерал оканчивается на первых не продублированных двойных кавычках. Чтобы собственно вставить в дословный литерал двойные кавычки, вам нужно их продублировать (""). Также, в отличие от стандартного литерала, в дословном могут присутствовать символы возврата каретки и переноса строки без экранирования. Для использования дословного литерала необходимо указать @ перед открывающей кавычкой. Ниже в таблице собраны примеры, демонстрирующие различия между описанными типами литералов.
Стандартный литерал Дословный литерал Результирующая строка
"Hello" @"Hello" Hello
"Обратный слеш: \\" @"Обратный слеш: \" Обратный слеш: \
"Двойная кавычка: \"" @"Двойная кавычка: """ Двойная кавычка: "
"CRLF:\r\nПосле CRLF" @"CRLF:
После CRLF"
CRLF:
После CRLF

Имейте ввиду, что стандартный и дословный литералы существуют только для вас и компилятора C#. Как только код скомпилирован, все литералы приводятся к единообразию.
Вот полный список специальных символов, требующих экранирования:
  • \' — одинарная кавычка, используется для объявления литералов типа System.Char
  • \" — двойная кавычка, используется для объявления строковых литералов
  • \\ — обратный слеш
  • \0 — null-символ в Юникоде
  • \a — символ Alert (№7)
  • \b — символ Backspace (№8)
  • \f —смена страницы FORM FEED (№12)
  • \n — перевод строки (№10)
  • \r — возврат каретки (№13)
  • \t — горизонтальная табуляция (№9)
  • \v — вертикальная табуляция (№11)
  • Uxxxx — символ Юникода с шестнадцатеричным кодом xxxx
  • \xn[n][n][n] — символ Юникода с шестнадцатеричным кодом nnnn, версия предыдущего пункта с переменной длиной цифр кода
  • \Uxxxxxxxx — символ Юникода с шестнадцатеричным кодом xxxxxxxx, используется для вызова суррогатных пар.

В своей практике я редко использую символы \a, \f, \v, \x и \U.

Строки и отладчик


Довольно часто при просмотре строк в отладчике (используя VS.NET 2002 и VS.NET 2003) люди сталкиваются с проблемами. Ирония в том, что эти проблемы чаще всего создаёт отладчик, пытаясь быть полезным. Иногда он отображает строку в виде стандартного литерала, экранируя обратными слешами все спецсимволы, а иногда он отображает строку в виде дословного литерала, оглавляя её @. Поэтому многие спрашивают, как удалить из строки @, хотя его там фактически нет. Кроме этого, отладчики в некоторых версиях VS.NET не отображают строки с момента первого вхождения null-символа \0, и что ещё хуже, неправильно вычисляют их длины, так как подсчитывают их самостоятельно вместо запроса к управляемому коду. Естественно, всё это из-за того, что отладчики рассматривают \0 как признак окончания строки.

Учитывая такую путаницу, я пришел к выводу, что при отладке подозрительных строк их следует рассматривать множеством способов, дабы исключить все недоразумения. Я предлагаю использовать приведённый ниже метод, который будет печатать содержимое строки в консоль «правильным» способом. В зависимости от того, какое приложение вы разрабатываете, вы можете вместо вывода в консоль записывать строки в лог-файл, отправлять в трассировщики, выводит в модальном Windows-окне и т.д.

static readonly string[] LowNames = 
 {
     "NUL", "SOH", "STX", "ETX", "EOT", "ENQ", "ACK", "BEL", 
     "BS", "HT", "LF", "VT", "FF", "CR", "SO", "SI",
     "DLE", "DC1", "DC2", "DC3", "DC4", "NAK", "SYN", "ETB",
     "CAN", "EM", "SUB", "ESC", "FS", "GS", "RS", "US"
 };
public static void DisplayString (string text)
 {
     Console.WriteLine ("String length: {0}", text.Length);
     foreach (char c in text)
     {
         if (c < 32)
         {
             Console.WriteLine ("<{0}> U+{1:x4}", LowNames[c], (int)c);
         }
         else if (c > 127)
         {
             Console.WriteLine ("(Possibly non-printable) U+{0:x4}", (int)c);
         }
         else
         {
             Console.WriteLine ("{0} U+{1:x4}", c, (int)c);
         }
     }
 }


Использование памяти и внутренняя структура


В текущей реализации .NET Framework каждая строка занимает 20+(n/2)×4 байт, где n — количество символов в строке или, что одно и то же, её длина. Строковый тип необычен тем, что его фактический размер в байтах изменяется им самим. Насколько я знаю, так могут делать только массивы. По факту, строка — это и есть массив символов, расположенный в памяти, а также число, обозначающее фактический размер массива в памяти, а также число, обозначающее фактическое количество символов в массиве. Как вы уже поняли, длина массива не обязательно равна длине строки, так как строки могут перераспределяться со стороны mscorlib.dll для облегчения их обработки. Так само делает, к примеру, StringBuilder. И хотя для внешнего мира строки неизменяемые, внутри mscorlib они ещё как изменяемые. Таким образом, StringBuilder при создании строки выделяет несколько больший символьный массив, нежели того требует текущий литерал, а потом прибавляет новые символы в созданный массив до тех пор, пока они «влезают». Как только массив заполняется, создаётся новый, ещё больший массив, и в него копируется содержимое из старого. Кроме этого, в числе, обозначающем длину строки, первый бит отведён под специальный флаг, определяющий, содержит ли строка не-ASCII символы или нет. Благодаря этому флагу исполняющая среда в некоторых случаях может проводить дополнительные оптимизации.

Хотя со стороны API строки не являются null-терминированными, внутренне символьные массивы, представляющие строки, являются. А это значит, что строки из .NET могут напрямую передаваться в неуправляемый код безо всякого копирования, предполагая, что при таком взаимодействии строки будут маршаллированы как Юникод.

Кодировки строк


Если вы не знакомы с кодировками символов и Юникодом, пожалуйста, прочтите сначала мою статью о Юникоде (или её перевод на Хабре).

Как я уже сказал вначале статьи, строки всегда хранятся в Юникод-кодировке. Всякие домыслы о Big-5-кодировках или UTF-8-кодировках являются ошибкой (по крайней мере, по отношению к .NET) и являются следствием незнания самих кодировок или того, как .NET обрабатывает строки. Очень важно понять этот момент — рассматривание строки как такой, которая содержит некий валидный текст в кодировке, отличной от Юникода, почти всегда является ошибкой.

Далее, набор символов, поддерживаемых Юникодом (одним из недостатков Юникода является то, что один термин используется для разных вещей, включая кодировки и схемы кодировок символов), превышает 65536 символов. А это значит, что один char (System.Char) не может содержать любой символ Юникода. А это приводит к понятию суррогатных пар, где символы с кодом выше U+FFFF представляются в виде двух символов. По сути, строки в .NET используют кодировку UTF-16. Возможно, большинству разработчиков и не нужно углубляться касательно этого в детали, но по крайней мере это стоит знать.

Региональные и интернациональные странности


Некоторые странности в Юникоде ведут к странностям при работе со строками и символами. Большинство строковых методов зависимы от региональных настроек (являются culture-sensitive — регионально-чувствительными), — другими словами, работа методов зависит от региональных настроек потока, в котором эти методы выполняются. Например, как вы думаете, что возвратит этот метод "i".toUpper()? Большинство скажут: «I», а вот и нет! Для турецких региональных настроек метод вернёт "İ" (код U+0130, описание символа: «Latin capital I with dot above»). Для выполнения регионально-независимой смены регистра вы можете использовать свойство CultureInfo.InvariantCulture и передать его как параметр в перегруженную версию метода String.ToUpper, которая принимает CultureInfo.

Есть и другие странности, связанные со сравнением и сортировкой строк, а также с нахождением индекса подстроки в строке. Некоторые из этих операций регионально-зависимы, а некоторые — нет. Например, для всех регионов (насколько я могу видеть) литералы «lassen» и «la\u00dfen» (во втором литерале шестнадцатеричным кодом указан символ «S острое» или «эсце́т») определяются как равные при передачи их в методы CompareTo или Compare, но вот если передать их в Equals, то будет определено неравенство. Метод IndexOf будет учитывать эсцет как «ss» (двойное «s»), но вот если вы используете одну из перегрузок CompareInfo.IndexOf, где укажете CompareOptions.Ordinal, то эсцет будет обработан правильно.

Некоторые символы Юникода вообще абсолютно невидимы для стандартного метода IndexOf. Однажды кто-то спросил в группе новостей C#, почему метод поиска и замены уходит в бесконечный цикл. Этот человек использовал метод Replace для замены всех сдвоенных пробелов одним, а потом проверял, окончилась ли замена и нет ли больше сдвоенных пробелов в строке, используя IndexOf. Если IndexOf показывал, что сдвоенные пробелы есть, строка снова отправлялась на обработку к Replace. К сожалению, всё это «ломалось», так как в строке присутствовал некий «неправильный» символ, расположенный точно между двумя пробелами. IndexOf сообщал о присутствии сдвоенного пробела, игнорируя этот символ, а Replace не выполнял замену, так как «видел» символ. Я так и не узнал, что это был за символ, но подобная ситуация легко воспроизводится при помощи символа U+200C, который является «не-связующим символом нулевой ширины» (англ. zero-width non-joiner character), что бы это не значило, чёрт возьми! Поместите такой или ему подобный в вашу строку, и IndexOf будет его игнорировать, а Replace — нет. Снова-таки, чтобы заставить оба метода работать одинаково, вы можете использовать CompareInfo.IndexOf и указать ему CompareOptions.Ordinal. Мне кажется, что уже написано достаточно много кода, который будет «валиться» на таких «неудобных» данных. И я даже не намекаю, что мой собственный код застрахован от подобного.

Microsoft опубликовала некоторые рекомендации касательно обработки строк, и хотя они датируются 2005-м годом, их всё ещё сто́ит прочесть.

Выводы


Для такого базового типа, как строка (да и вообще для текста в общем), строка в .NET является намного сложней, нежели вам может показаться. Очень важно понять основы, описанные в этой статье, даже если некоторые нюансы сравнения и регистра строк в мульти-региональных контекстах будут от вас ускользать. В частности, жизненно важной является способность диагностировать ошибки кодировок строк, правильно логируя эти самые строки.


От переводчика: Так как статья относительно не новая, я решил проверить описанные Джоном Скитом «странности» и проблемы в строках. В результате, мне удалось воспроизвести всё то, что описано в разделе «Региональные и интернациональные странности», используя версии .NET Framework 3.5, 4.0 и 4.5 включительно. Вместе с тем странности касательно отображения литералов в отладчике, описанные в разделе «Строки и отладчик», я не встречал ни разу, по крайней мере в MS Visual Studio 2008, 2010 и 2012 включительно.
Перевод: Jon Skeet
Денис @Klotos
карма
38,2
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

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

  • 0
    каждая строка занимает 20+(n/2)×4

    А можно поподробней, почему n/2?
    • 0
      В статье Джон Скит не расписывает механизм хранения строк детально, да и я не могу утверждать, что являюсь экспертом во внутренностях .NET, однако полагаю, формула 20+(n/2)×4 связана с тем, что каждый символ хранится в UTF-16 и занимает 2 байта. 20 — это вспомогательная информация. А вот почему (n/2)×4, а не n×2 — к сожалению, не знаю.
      • 0
        Возможно, символы как раз хранятся по 2 в 32битных ячейках. Соответственно, суррогатные пары хранятся каждая в целой ячейке, а формула их просто не учитывает.
      • +2
        Возможно, память выделяется дискретами по 32 бита?
      • 0
        А вот почему (n/2)×4, а не n×2 — к сожалению, не знаю.

        Есть мнение, что это оптимизация доступа к памяти.
      • +3
        Ну по виду обычное выравнивание в 4 байта.
      • +8
        В оригинальной статье написано:

        > strings take up 20+(n/2)*4 bytes (rounding the value of n/2 down)

        (n / 2) — целочисленное деление (13 / 2 == 6; 12 / 2 == 6)
        • +1
          В таком случае, строка, длинной в 1 символ, будет занимать 20 + (1/2) * 4 = 20 байт? Значит один из символов входит во вспомогательную информацию?
          • 0
            Ну это вопрос не ко мне, а к Джону Скиту или разработчикам CLR :)
          • –3
            20 + (1/2) * 4 = 20 + 0.5 * 4 = 22
            • 0
              Округлить 0.5 в меньшую сторону забыли.
              • +1
                Ну почему же забыли!
                Возможно, это 16 + (n/2+1)*4 = 20 + (n/2)*4
                • 0
                  нет, 20 + 4 * n / 2
              • 0
                ничего не забыл, так что все верно, вообще в таких случаях нужно всегда 1-м делать умножение и потом уже делить, тогда результат будет намного точнее
                • 0
                  Вы видели rounding the value of n/2 down?
                  И как можно в «таких случаях» делать умножение первым, когда скобки явно указывают на порядок вычисления?
                  • 0
                    В данном случае либо недопонимание или автор не дорассказал. Если n = 1, то получается 0, что неверно, не может один символ храниться в 0 байтах, а если n = 3, то получится 4 байта, как вы себе представляете 3 символа в 4-х байтах? Ну и т. д.
                    Формулу (n/2)*4 нужно читать как два символа в 4-х байтах, причем конечный результат всегда выровнен по границе 4, а для этого нужно было применять такую формулу ((n+1)/2) * 4
                    • +1
                      Легко, если на самом деле данные об объекте занимают 18 байт (но в другой статье Скит говорит о 14 байтах), а выровнено в 4 байта, тогда два байта из 20 можно использовать под один символ. И формулу нужно читать как Math.Floor(n/2)*4.
                      • 0
                        Хабр — такой хабр :)
                        Полемика по поводу пары байт скоро перерастет в несколько гигабайт, хранящихся в БД Хабра в виде комментариев )
      • 0
        Оказывается все проще: пустая строка тоже занимает 2 байта (т.е. значение null). А его формула просто «убирает» из 20 эти 2 байта с помощью округления, если строка не null.

        Сам Скит скинул ссылку на более новую статью, где размер уже высчитывается как 14 + length * 2 для x86. Какая статья точнее он не уверен.
        • +1
          Хотя нет, null здесь не при чем, действительно простое выравнивание.
    • +1
      Нашёл такую информацию:
      A string is composed of:
      • An 8-byte object header (4-byte SyncBlock and a 4-byte type descriptor)
      • An int32 field for the length of the string (this is returned by String.Length).
      • An int32 field for the number of chars in the character buffer.
      • The first character of the string in a System.Char.
      • The rest of the string in a character buffer, terminating with a null terminator char.

      Насколько правдива и актуальна — неизвестно.
      • +2
        Небольшой тест в Visual Studio под .NET Framework 4.5 и x64 показал, что объект String состоит из:
        • 4-byte SyncBlock
        • 8-byte type descriptor (или 4 byte для 32-bit)
        • An int32 field — длина строки
        • символы по 2 байта, без null terminator
  • +1
    По поводу интернирования можно было бы добавить, что его можно отлючить специальным атрибутом для проекта.
  • +1
    Вероятно, пул интернирования является особенностью именно .NET

    В JVM тоже существует intern.
    • 0
      Интересно, в чём главные отличия между JVM и .NET intern pool'ом?
    • +3
      Если внимательно вчитаться, то можно увидеть, что имелось ввиду, что интернирование является фичей не конкретного языка на платформе .NET, а платформы в целом. О его наличии или отсутствии в других языках или платформах не сказано ровным счётом ничего.
      • 0
        Возможно, Вы правы.
  • +1
    >>Метод IndexOf будет учитывать эсцет как «ss» (двойное «s»), но вот если вы используете одну из перегрузок CompareInfo.IndexOf, где укажете CompareOptions.Ordinal, то эсцет будет обработан правильно.

    Странно в данном случае говорить о «правильности». Это особенности, которые надо знать (не все из них я знаю), но то что Zurich и Zürich — одно и тоже вполне нормально. Про такое поведение, насколько я помню, написано в CLR via C#, и плох разработчик, который ни разу его не читал.
    • 0
      В оригинале это предложение звучит как «IndexOf will treat the eszett as the same as „ss“, unless you use a CompareInfo.IndexOf and specify CompareOptions.Ordinal as the options to use.», слово «правильно» подобрал я при переводе. Вместе с тем, насколько я понял Джона Скита, его «смущает» (если можно так выразиться) не столько то, как обрабатываются подобные символы, а то, что разными функциями они обрабатываются по-разному.
      • 0
        В данном случае все логично. Есть поведение по-умолчанию, но иногда же надо работать с точностью до символа, для таких случаев есть опции.
        В случае Equals и Compare тоже все логично. Согласно МСДН: первый сравнивает значения, второй определяет алфавитный порядок. Е и Ё, к примеру, тоже приравниваются в словарях (бумажных).
        Это все — логичное поведение, таким образом спроектированное. Другой вопрос, что при переходе с того же c++ (или другого языка постарше) это непривычное поведение.
    • 0
      плох разработчик, который ни разу его не читал
      Чем плох?
      • 0
        Тем, что не читал. Там множество подобных тонкостей расписано.
  • 0
    > Никак невозможно изменить содержимое созданной строки, по крайней мере в управляемом коде и без рефлексии.
    Можно, используя unsafe.
    Пошёл смотреть оригинал, там правильно: «safe code» переводится не «управляемый» (это managed), а «безопасный».
    • 0
      Согласен, исправил.
  • 0
    Как жаль Как жаль, я тоже недавно хотел перевести эту статью…
    Помоему это наследние этой статьи habrahabr.ru/post/164193/

    Только в более академической форме!!!
    • 0
      Кто не успел, тот опоздал :) Если серьезно, то в Джона Скита вполне достаточно прекрасных статей для перевода. Вы можете сообщить мне, которые статьи вы желаете перевести в будущем, и я не буду их трогать.
  • 0
    … символом Юникода в диапазоне от U+0000 до U+FFFF ...

    В оригинальной статье написано «character» (может для упрощения понимая?), хотя по стандарту Unicode правильно это называется «code point», т.к. там кроме «characters» есть куча управляемых непечатаемых символов. Всегда интересно было как корректно перевести «code point» на русский…
  • +1
    (англ. zero-width non-joiner character), что бы это не значило, чёрт возьми!


    В некоторых языках, например в арабском, буквы одного слова могут менять свое начертание в зависимости от того, в какой части слова они находятся, т.н. «вязь». Вставленный между двумя буквами, non-joiner отключает этот механизм и буквы будут отображены так, как если бы они были написаны сами по себе, вне слова. Zero-with означает, что сам non-joiner не отображается. Обработка joiner'ов, mark-ов и прочих спецсредств юникода как символов — это ошибка реализации библиотек.

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