Pull to refresh

String.Intern делает строки ещё интереснее

Reading time 10 min
Views 66K
Original author: Andrew Stellman
Предисловие от переводчика:

Проходя/проводя собеседования, приходится сталкиваться с вопросами, которые раскрывают общее понимание работы .NET. По моему мнению, наибольшей любовью среди таких вопросов пользуются вопросы о работе “сборщика мусора”, но однажды мне был задан вопрос о интернировании строк. И он, честно говоря, поставил меня в тупик. Поиск в рунете выдал несколько статей, но они не давали ответы на те вопросы, которые я искал. Надеюсь мой перевод статьи Эндрю Стеллмана (автора книги “Head First C#”) заполнит этот пробел. Думаю, этот материал будет полезен для начинающих .NET разработчиков и тем кому стало интересно, что же такое интернирование строк в .NET.

String.Intern делает строки ещё интереснее


Одна из первых вещей, с которой сталкивается каждый начинающий C# разработчик — это работа со строками. Я показываю основу работы со строками в начале «Head First C#», как поступают практически в любой другой книге по C#. Так что не следует удивляться, что C# разработчики уровня джуниор и мидл уровня чувствуют, что они получили довольно хорошую базу по строкам. Но строки интереснее, чем кажутся. Одним из самых интересных аспектов строк в C# и .NET является метод String.Intern. Понимание работы этого метода может улучшить ваши навыки в C# разработке. В этом посте, я сделаю краткий туториал для метода String.Intern, чтобы показать вам как он работает.

Примечание: В конце этого поста я собираюсь показать кое-что «под капотом», используя ILDasm. Если вы никогда не работали с ILDasm раньше, это будет хорошей возможностью что-бы познакомиться с очень полезным инструментом .NET.

Некоторые основы работы со строками

Давайте начнем с краткого обзора того, что ожидают от класса System.String. (Я не буду вдаваться в подробности — если кто-то хочет пост о основах строк в .NET, добавьте комментарий или свяжитесь со мной на Building Better Software, и я буду рад обсудить возможную статью вместе!)

Создайте новое консольное приложение в Visual Studio. (Все точно так же работает из командной строки, если вы хотите использовать csc.exe для компиляции кода, но ради легкости восприятия материала давайте придерживаться разработки в Visual Studio.) Вот код метода Main() — точки входа консольного приложения:

Program.cs:

using System;

class Program
{
     static void Main(string[] args)
     {
          string a = "hello world";
          string b = a;
          a = "hello";
          Console.WriteLine("{0}, {1}", a, b);
          Console.WriteLine(a == b);
          Console.WriteLine(object.ReferenceEquals(a, b));
     }
}

В этом коде не должно быть никаких сюрпризов. Программа выводит три строки на консоль (помните, если вы работаете в Visual Studio, используйте Ctrl-F5, чтобы запустить программу вне отладчика; также в программу будет добавлено «Press any key ...», что-бы предотвратить закрытие окна консоли):

hello, hello world
False
False

Первый WriteLine() выводит две строки. Второй сравнивает их с помощью оператора равенства ==, который возвращает False, потому что строки не совпадают. И последний сравнивает их, чтобы увидеть не ссылаются ли обе переменные на один и тот же объект String. Поскольку это не так, метод отображает значение False.
Затем добавьте эти две строки в конец метода Main():

        Console.WriteLine((a + " world") == b);
        Console.WriteLine(object.ReferenceEquals((a + " world"), b));

И опять вы получите довольно очевидный ответ. Оператор равенства возвращает True, так как обе строки равны. Но когда вы использовали конкатенацию строк «Hello» и «world», оператор + объединяет их и возвращает новый экземпляр System.String. Именно поэтому object.ReferenceEquals() вполне резонно возвращает False. Метод ReferenceEquals() возвращает True только в том случае, если оба аргумента ссылаются на один и тот же объект.
Такой способ позволяет нормально работать с объектами. Два разных объекта могут иметь одинаковые значения. Такое поведение является вполне практичным и предсказуемым. Если вы создаете два объекта “дом” и установите всем их свойствам одинаковые значения, вы будете иметь два одинаковых объекта типа “дом”, но это будут различные объекты.

Это все еще кажется немного запутанным? Если так, то я определенно рекомендую обратить внимание на несколько первых глав “Head First C#”, которые дадут вам представление о написании программ, отладке, и использование объектов и классов. Вы можете скачать их как бесплатные вырезки из этой книги.
Итак, пока мы работаем со строками — все прекрасно. Но как только мы начинаем играться ссылками на строки, все становится немного странным.

Что-то с этой ссылкой не так ...

Создайте новое консольное приложение. Код ниже для него. Но, перед компиляцией и выполнением, внимательно посмотрите на код. Попробуйте угадать, что он отобразит в консоли?

Program.cs:

using System;

class Program
{
     static void Main(string[] args)
     {
          string hello = "hello";
          string helloWorld = "hello world";
          string helloWorld2 = hello + " world";
  
          Console.WriteLine("{0}, {1}: {2}, {3}", helloWorld, helloWorld2,
              helloWorld == helloWorld2,
              object.ReferenceEquals(helloWorld, helloWorld2));
      }
}

Теперь запустите программу. Вот то, что она отобразит в консоли:

hello world, hello world: True, False

И так, это именно то, что мы ожидали. В объектах helloWorld и helloWorld2 строки содержат “Hello world", так что они равны, но ссылки разные.
Теперь добавьте в нижней части вашей программы этот код:

    helloWorld2 = "hello world";
    Console.WriteLine("{0}, {1}: {2}, {3}", helloWorld, helloWorld2,
        helloWorld == helloWorld2,
        object.ReferenceEquals(helloWorld, helloWorld2));

Запустите его. На этот раз код отобразит в консоли следующую строку:

hello world, hello world: True, True

Подождите, получается что сейчас HelloWorld и HelloWorld2 ссылаться на одну и ту же строку? Наверное, некоторым может показаться такое поведение странным или, по крайней мере, немного неожиданным. Мы не меняли значение helloWorld2 вообще. Многие в конечном итоге думают что-то вроде этого: “переменная была уже равна «hello world». Установка в «hello world» ещё один раз не должна ничего изменить.” Так в чем же дело? Давайте разберёмся.

Что такое String.Intern? (погружаясь в пул интернирования ...)

При использовании строк в C#, CLR делает что-то хитрое и это что-то называется интернирование строк. Это способ хранения одной копии любой строки. Если вы храните в ста или, что еще хуже, в миллионе строковых переменных одинаковое значение получится, что память для хранения значений строк будет выделяться снова и снова. Интернирование строки это способ обойти эту проблему. Среда CLR поддерживает таблицу называемую пул интернирования. Эта таблица содержит одну уникальную ссылку на каждую строку, которая либо объявлена, либо создана программно во время выполнения вашей программы. А .NET Framework предоставляет два полезных метода для взаимодействия с пулом интернирования: String.Intern() и String.IsInterned().

Метод String.Intern() работает очень простым способом. Вы передадите ему в качестве аргумента строку. Если эта строка уже находится в пуле интернирования, метод возвращает ссылку на эту строку. Если её еще не нет, он добавляет строку в пул и возвращает на неё ссылку. Вот пример:

         Console.WriteLine(object.ReferenceEquals(
            String.Intern(helloWorld), 
            String.Intern(helloWorld2)));

Этот код будет отображать True, даже если HelloWorld и HelloWorld2 ссылки на два разных строковых объекта, потому что они оба содержат строку «Hello World».
Остановитесь на минутку. Стоит ещё немного поразбираться с String.Intern() потому, что иногда метод дает немного нелогичные на первый взгляд результаты. Вот пример такого поведения:

        string a = new string(new char[] {'a', 'b', 'c'});
        object o = String.Copy(a);
        Console.WriteLine(object.ReferenceEquals(o, a));
        String.Intern(o.ToString());
        Console.WriteLine(object.ReferenceEquals(o, String.Intern(a)));

Выполнение кода выведет две строки на консоль. Первый метод WriteLine() покажет значение False, и это понятно, так как метод String.Copy() создает новую копию строки и возвращает ссылку на новый объект. Но почему выполнив вначале String.Intern(о.ToString()) затем String.Intern(a) вернёт ссылку на о? Остановитесь на минутку, чтобы подумать об этом. Это становится еще более нелогичным, если вы добавите еще три строки:

        object o2 = String.Copy(a);
        String.Intern(o2.ToString());
        Console.WriteLine(object.ReferenceEquals(o2, String.Intern(a)));

Похоже, эти строчки кода сделали то же самое, только с новой переменной объекта o2. Но в последнем WriteLine() выведет значение False. Так что же происходит?

Этот небольшой беспорядок поможет нам разобраться, что происходит под капотом String.Intern() и пула интернирования. Первое, что необходимо уяснить для себя это то, что метод строкового объекта в ToString() всегда возвращает ссылку на самого себя. Переменная o указывает на объект строки, содержащий значение «abc», поэтому вызов собственного метода ToString() возвращает ссылку на эту строку. Итак, вот что происходит.
В начале а указывает на объект строки №1, в котором содержится «abc». Переменная о указывает на другой объект строки №2 который также содержит «abc». Вызов String.Intern(o.ToString()) добавляет ссылку на строку №2 в пул интернирования. Теперь, когда объект строки №2 находится в пуле интернирования, в любое время String.Intern() вызывая с параметром «abc» будет возвращать ссылку на объект строки №2.
Поэтому, когда вы передаёте пременную о и String.Intern(а) в метод ReferenceEquals(), он возвращает True, потому что String.Intern(а) вернула ссылку на объект строки №2. Теперь мы создали новую переменную o2 и использовали метод String.Copy(), что бы создать еще один объект типа String. Это будет объект строки №3, который также содержит строку «abc». Вызов String.Intern(o2.ToString()) ничего не добавляет к пулу интернирования на этот раз, потому что «abc» уже есть, но вернёт указатель на строку №2.
Так что этот вызов Intern() фактически возвращает ссылку на строку №2, но мы отбрасываем его вместо того, чтобы присвоить переменной. Мы могли бы сделать что-то вроде этого: string q = String.Intern(o2.ToString()), что сделало бы переменную q ссылкой на объект строки №2. Именно поэтому, последний WriteLine() выводит False так как это сравнение ссылки строки №3 со ссылкой на строку №2.

Используйте String.IsInterned() для проверки, является ли строка в пуле интернирования

Есть другой, несколько парадоксально названный метод, который полезен при работе с интернированными строками: String.IsInterned(). Он принимает ссылку на объект строки. Если эта строка находится в пуле интернирования, он возвращает ссылку на интернированную строку строки, если она еще не находится в пуле интернирования, то метод возвращает null.
Причина, по которой его название звучит немного нелогичным в том, что этот метод начинается с «Is» но при этом возвращает не булев тип, как ожидают многие программисты.
При работе с методом IsInterned() для отображения того что строка отсутствует в пуле интернирования удобно использовать null-коалесцирующий оператор — ??. К примеру написав:

        string o = String.IsInterned(str) ?? "not interned";

Теперь в переменную о вернется результат IsInterned() если он не нулевой, или строка «not interned», если строки нет в пуле интернирования.
Если этого не сделать, то метод Console.WriteLine() будет выводить пустые строки (что делает этот метод, когда сталкивается null).
Вот простой пример того, как String.IsInterned() работает:

        string s = new string(new char[] {'x', 'y', 'z'});
        Console.WriteLine(String.IsInterned(s) ?? "not interned");
        String.Intern(s);
        Console.WriteLine(String.IsInterned(s) ?? "not interned");
        Console.WriteLine(object.ReferenceEquals(
        String.IsInterned(new string(new char[] { 'x', 'y', 'z' })), s));

Первый WriteLine() оператор отобразит в консоли «not interned», потому что «xyz» еще нет в пуле интернирования. Второй WriteLine() оператор печатает «xyz» потому, что пул интернирования уже содержит «xyz». И третий WriteLine () выведет True, так как объект s указывает на объект, добавленный в пул интернирования.

Литералы интернируются автоматически

Добавив всего одну строку в конец метода и запустив программу снова:

         Сonsole.WriteLine(object.ReferenceEquals("xyz", с));

произойдет что-то совсем неожиданное!
Программа никогда не отобразит «not interned», а последние два метода WriteLine() покажут False! Если мы закомментируем последнюю строку, то программа действует именно так, как вы ожидали. Почему?! Как добавив код в конце программы, поменялось поведение программы кода над ним? Это очень, очень странно!

Это кажется действительно странным в первый раз, когда вы сталкиваетесь с этим, но в этом действительно есть смысл. Причина изменения поведения всей программы в том, что код содержит литерал «xyz». А когда вы добавляете литерал в вашу программу, CLR автоматически добавляет его в пул интернирования ещё до начала выполнения программы. Комментируете эту строку, вы убираете литерал из программы и пул интернирования уже не будет содержать строку «xyz».
Понимая, что «xyz» уже находится в пуле интернирования при запуске программы, так как эта строка в виде литерала появилась в коде, то сразу стаёт понятным такое изменение в поведении программы. String.IsInterned(s) больше не возвращает null. Вместо этого, он возвращает ссылку на литерал «xyz», что также объясняет, почему ReferenceEquals() возвращает False. Это происходит из за того, что строка s никогда не будет добавлена в пул интернирования («xyz» уже в пуле, указывая на другой объект).

Компилятор умнее, чем вы думаете!

Измените последнюю строку кода на эту:

         Console.WriteLine(
            object.ReferenceEquals("x" + "y" + "z", s));

Запустите программу. Она работает точно так же, как если бы вы использовали литерал «xyz»! Неужели + не оператор? Разве это не метод, который запускается на выполнение по CLR во время выполнения? Если это так, то должен быть код, который предотвратит интернирование литерала «xyz».
В самом деле так и произойдёт если вы замените «х» + «у» + «z» на String.Format("{0}{1}{2}", 'x', 'y', 'z'). Обе строчки кода возвращают «xyz». Почему же при помощи оператора + для конкатенации получаем поведение, как если бы вы использовали литерал «xyz», хотя в тоже самое время как String.Format() выполняется во время выполнения?
Самый простой способ ответить на этот вопрос — это увидеть то, что на самом деле получаем при компиляции кода «x» + «у» + «z» .

Program.cs:

using System;

class Program 
{
         public static void Main() 
          {
                  Console.WriteLine("x" + "y" + "z");
          }
}

Следующим шагом нужно выяснить, что компилятор собрал приложение исполняемого типа. Для этого мы будем использовать ILDasm.exe, дизассемблер MSIL. Этот инструмент устанавливается с каждой версией Visual Studio (в том числе и изданий Express). И даже если вы не знаете, как читать IL, вы сможете понять, что происходит.

Запустите Ildasm.exe. Если вы используете 64-разрядную версию Windows, выполните следующую команду: "%ProgramFiles (x86)%\Microsoft SDKs\Windows\v7.0A\Bin\Ildasm.exe" (включая кавычки), либо из Пуск >> окно Run, либо из командной строки. Если вы используете 32-разрядную версию Windows, вам стоит выполнить следующую команду: "%ProgramFiles%\Microsoft SDKs\Windows\v7.0A\Bin\ildasm.exe".

Если у вас .NET Framework 3.5 или более ранней версии
Если у вас .NET Framework 3.5 или более ранней версии, возможно, потребуется поискать ildasm.exe в соседних папках. Запустите окно проводника и перейдите в папку Program Files. Как правило нужная пограмма находится в папке «Microsoft SDKs\Windows\vX.X\bin». Кроме того вы можете запустить командную строку из «Visual Studio Command Prompt» которая находится в меню Пуск, после чего набрать «ILDASM» для его запуска.


Вот так, выглядит ILDasm при первом запуске:



Затем скомпилируйте свой код в исполняемый файл. Кликните на проект в Solution Explorer — в окне Properties должно располагаться поле Project Folder. Дважды кликните по нему и скопируйте. Перейдя в окно ILDasm, выберите Файл >> Открыть в меню, и вставьте путь к папке. Затем перейдите в папку «bin». Ваш исполняемый файл должен находиться либо в папке bin\Debug или bin\Release. Откройте исполнимый файл. ILDasm должен показать вам содержимое сборки.



(Если вам нужно освежить память о том, как создаются сборки, см. этот пост для понимания C# и .NET сборок и пространств имен ).
Разверните класс Program и дважды щелкните на методе Main(). После этих действий должен появиться дизасамблированный код метода:



Вам не нужно знать IL чтобы увидеть наличие литерала «xyz» в коде. Если закрыть ILDasm, а затем изменить код, чтобы использовать «xyz» вместо «х» + «у» + «z», разобрали IL код выглядит точно так же! Это потому, что компилятор достаточно умен, чтобы заменить «х» + «у» + «z» на «xyz» во время компиляции, так что не придётся тратить лишние операции на вызовы методов, которые всегда будет возвращать «xyz». А когда литерал компилируется в программе, то CLR добавляет его в пул интернирования при запуске программы.

Материал данной статьи должен дать вам хорошее представление о интернировании строк в C# и .NET. В принципе этого даже больше чем нужно для понимания работы интернирования строк. Если вы заинтересованы в получении дополнительной информации, хорошим плацдармом является раздел «Performance Considerations» на страницах MSDN о String.Intern.

P.S.: Спасибо команде за усердную вычитку и объективную критику перевода.
Tags:
Hubs:
+16
Comments 16
Comments Comments 16

Articles