Строки в C# и .NET

в 10:23, , рубрики: .net, string

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».

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

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

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

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

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

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

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

В .NET существует понятие «пула интернирования» (intern pool). По своей сути это всего лишь набор строк, но он обеспечивает то, что когда вы в разных местах программы используете разные строки с одним и тем же содержимым, то это содержимое будет храниться лишь один раз, а не создаваться каждый раз по-новому. Вероятно, пул интернирования является особенностью именно .NET, однако он определённо существует в 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:rnПосле CRLF" @"CRLF:
После CRLF"
CRLF:
После CRLF

Имейте ввиду, что стандартный и дословный литералы существуют только для вас и компилятора C#. Как только код скомпилирован, все литералы приводятся к единообразию.
Вот полный список специальных символов, требующих экранирования:

  • ' — одинарная кавычка, используется для объявления литералов типа System.Char
  • " — двойная кавычка, используется для объявления строковых литералов
  • \ — обратный слеш
  • — 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-символа , и что ещё хуже, неправильно вычисляют их длины, так как подсчитывают их самостоятельно вместо запроса к управляемому коду. Естественно, всё это из-за того, что отладчики рассматривают как признак окончания строки.

Учитывая такую путаницу, я пришел к выводу, что при отладке подозрительных строк их следует рассматривать множеством способов, дабы исключить все недоразумения. Я предлагаю использовать приведённый ниже метод, который будет печатать содержимое строки в консоль «правильным» способом. В зависимости от того, какое приложение вы разрабатываете, вы можете вместо вывода в консоль записывать строки в лог-файл, отправлять в трассировщики, выводит в модальном 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» и «lau00dfen» (во втором литерале шестнадцатеричным кодом указан символ «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 включительно.

Автор: Klotos

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js