Интернирование строк и String.Empty

Вот любопытный фрагмент кода:

object obj = "Int32";
string str1 = "Int32";
string str2 = typeof(int).Name;
Console.WriteLine(obj == str1); // true
Console.WriteLine(str1 == str2); // true
Console.WriteLine(obj == str2); // false !?

Конечно, если A равно B, и B равно C, то A равно C, это транзитивное своейство равенства. Похоже, что оно тут полностью нарушено.

Ну, прежде всего, хотя транзитивность и желательна, это всего лишь одна из многих ситуаций, в которых равенство в C# нетранзитивно. Вы не должны полагаться на транзитивность в общем случае, хотя, конечно же, есть множество частных случаев, где она справедлива. В качестве упражнения, вы могли бы посмотреть, сколько других нетранзитивностей сможете вспомнить. (Случайно, один из вопросов, который мне задали на собеседовании при приёме в эту команду был про разработку производительного алгоритма для определения нетранзитивностей в упрощённой версии алгоритма «наилучшего метода».)

Во-вторых, здесь происходит смешение двух разных видов равенства, которые случайно используют одинаковый синтаксис операторов. Мы смешиваем равенство по ссылке с равенством по значению. Объекты сравнивают по ссылке; в первом и третьем сравнении мы проверяем, что обе ссылыки указывают в точности на один и тот же объект. Во втором сравнении мы проверяем, одинаково ли содержимое двух строк, вне зависимости от того, представлены ли они одним объектом. Фактически, компилятор предупреждает вас о такой ситуации; такой код должен порождать предупреждение «возможно непреднамеренное сравнение ссылок».

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

Это объясняет, почему второе сравнение истинно – это сравнение значений – и почему третье сравнение ложно – это сравнение ссылок. Но это не объясняет, почему первое и третье сравнения противоречат друг другу.

Это результат маленькой оптимизации. Если у вас есть два идентичных строковых литерала в одной единице компиляции, то код, который мы генерируем, гарантирует, что только один объект строки создаётся CLR для всех экземпляров этого литерала в пределах сборки. Эта оптимизация называется «интернированием строк».

String.Empty – не константа, это поле только-для-чтения в другой сборке. Поэтому оно не интернируется с пустой строкой в вашей сборке; это два разных объекта.

Это объясняет, почему первое сравнение истинно: эти два литерала фактически превращаются в один и тот же объект строки. И это объясняет, почему третье сравнение ложно: литерал и вычисленное значение превращаются в разные объекты.

Зная это, вы теперь можете сделать обоснованное предположение причины, по которой мы имеем это противоестественное поведение:

object obj = "";
string str1 = "";
string str2 = String.Empty;
Console.WriteLine(obj == str1); // true
Console.WriteLine(str1 == str2); // true
Console.WriteLine(obj == str2); // иногда true, иногда false?!

Некоторые версии .NET автоматически интернируют пустую строку, некоторые – нет!

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

Ответ – в том, что принцип ЛДНБ применим здесь в полной мере. То есть, Ланчей Даром Не Бывает. Интернирование имеет два положительных эффекта: оно уменьшает потребление памяти и уменьшает время сравнения двух строк. (Потому, что если все строки интернируются во время выполнения, то все сравнения строк могут быть дешёвыми сравнениями ссылок.) Но у этих положительных эффектов есть цена: выделение новой строки теперь требует от вас поиска по всем строкам в памяти для проверки, нет ли там уже такой. В нашей существующей оптимизации, цена невелика; во время компиляции нам известно, каковы строковые литералы в данной сборке и какие из них одинаковы. В предлагаемой оптимизации, эта цена платится во время выполнения, и может занимать весьма большую долю затрат времени на выделение строк.

Чтобы снизить затраты времени, вам бы пришлось построить хеш-таблицу всех строк в памяти. Это означает либо частое вычисление хеш-кодов, что само по себе затратно по времени, либо их хранение где-то. Если мы выбираем последнее, то внезапно мы увеличиваем нагрузку на память для строк, которые не дублируются. То есть, наша оптимизация заставляет обычный сценарий – подавляющее большинство пар строк не совпадают друг с другом – требовать больше памяти, чтобы редкий сценарий мог её сэкономить. Это выглядит плохой сделкой; обычно вы хотите оптимизировать для более вероятного случая.

Кроме того, у интернированных строк есть также и серьёзные проблемы с временем жизни. Когда их можно безопасно подвергнуть сборке мусора? Что, если новая копия строки создаётся в тот момент, когда старую удаляет сборщик в другом потоке? Самое безопасное – сделать интернированные строки бессмертными, что выглядит как утечка памяти. Утечки памяти плохо влияют на производительность, особенно когда ваша оптимизация – это попытка сэкономить память. ЛДНБ!

Короче, в общем случае не стоит интернировать все строки. Тем не менее, это может оказаться стоящим в некоторых особых случаях. Например, если бы вы писали на C# компилятор, то вы бы скорее всего порождали в процессе выполнения большое количество одинаковых строк. Наш компилятор C# написан на C++, где мы написали нашу собственную прослойку интернирования строк, чтобы мы могли выполнять дешёвые ссылочные сравнения для всех строк в вашей программе. Весьма вероятно, что «int» будет встречаться десятки, сотни, или тысячи раз в одной программе; глупо выделять одну и ту же строку снова и снова. Если вы пишете на C# компилятор, или какое-то другое приложение, в котором вы чувствуете, что стоит постараться не дать тысячам идентичных строк потребить много памяти, то вы можете заставить среду исполнения интернировать вашу строку при помощи метода String.Intern.

И, наоборот, если вы ненавидите интернирование слепой ненавистью, то можете заставить среду отключить всё интернирование строк в сборке при помощи атрибута CompilationRelaxation.

В любом случае, вернёмся к вопросу транзитивности: равенство ссылок на объекты на самом деле транзитивно. Оно также симметрично (A==B подразумевает B==A) и рефлексивно (A==A), так что это отношение эквивалентности. Похожим образом, равенство значений строк транзитивно, симметрично и рефлексивно, поскольку оно использует прямолинейное сравнение «символ за символом». Но когда вы смешиваете их, то равенство перестаёт быть транзитивным. Это странно, но, надеюсь, теперь доступно для понимания.