воскресенье, апреля 22, 2007

Строки в C#

Работа со строками всегда специфична для разных языков программирования. Не обошлось без своей специфики и в C#. Тема вобщем-то не новая и уже обсуждалась много раз, но намедни я столкнулся с ней вновь.
Пришлось мне разбираться с одним багом: web сервис в структурах выходных данных местами выдавал пустые строки. Разбирательства вывели меня на вот этот метод:


protected static void TryToFill(string target, string source)
{
if (source != null && source.Length > 0)
{
target = source;
}
}


Как вы думаете, что выдаст на консоль вот такой код:


string s = "source";
string t = "";
TryToFill(t, s);
Console.WriteLine("target = '{0}'",t);


На выходе получаем:


target = ‘’


Метод TryToFill был предназначен для заполнения строки target данными из строки source. Но он не работает. Строка target после его вызова остается пустой
Строки в C# - ссылочные типы, память под них выделяется в куче. Передача в метод ссылочного типа в качестве параметра означает передачу ссылки на объект. Все изменения с параметром ссылочного типа внутри метода будут видны снаружи этого метода. Можно проверить. Массив - типичный ссылочный тип в C# - вот с ним и поэксперементируем:


public static void Change(byte[] val)
{
val[0] +=1;
}


Теперь создаем массив, передаем его в метод и смотрим его состояние до и после.


byte[] b = new byte[]{1,2};
Console.WriteLine("b[0] before Change() is '{0}'", b[0]);
Change(b);
Console.WriteLine("b[0] after Change() is '{0}'", b[0]);


Получаем:


b[0] before Change() is '1'
b[0] after Change() is '2'


Что и требовалось доказать. Но почему такой подход не работает со строками?
Все это из-за того, что в .Net строки сделаны immutable. Это означает, что однажды созданный объект больше никогда не меняет своего состояния. Для этого класс String сделан sealed, у него переопределены операции сравнения, доступ к символам строки возможен только на чтение, а при присваивании значения строке компилятор шаманит и создает новый экземпляр строки.
Что происходит в злополучном методе TryToFill? После вызова метода параметр target ссылается туда же, куда и переменная t. Но при присваиваниии target = source, создается копия значения source, на которую теперь ссылается target, а значение пременной t остается неизменным.
Для чего все это нужно?
Вот что говорит на эту тему Brian Harry в "Conversations on .NET":
Q: In reference to string manipulation, it appears that we can no longer directly manipulate the buffer in managed code. For example, string[0] = 'a'. Instead we do something like string.Replace(0, 'a') and get a new string back.

Brian Harry: Yes, the reason for this is that strings are immutable. This makes for a simpler programming model and eliminates problems around synchronization and sharing of strings. In some cases, it allows us to give better performance by avoiding copying strings.

Как ни странно, основной резон – повысить производительность. Память для экземпляра строки один раз выделяется в куче и после этого строка никогда никуда не копируется. Когда нам кажется, что в программе строке присваивается новое значение, происходит выделение памяти под новое значение строки, и ссылка на эту память присваивается переменной. Старое значение становится добычей сборщика мусора. Как известно выделение памяти в CLR происходит очень быстро, и сборщик мусора тоже весьма эффективен. Так что такое решение вполне оправдано.
Еще один положительный момент (которым впрочем почти никто не пользуется) это то, что доступ к строковым переменным из нескольких потоков не надо синхронизировать.

14 комментариев:

Анонимный комментирует...

"Как известно выделение памяти в CLR происходит очень быстро"

Все зависит от того, где именно происходит выделение памяти. При разработке своего проекта (который как раз на 99% состоит из работы со строками) столкнулся с тем, что на операции new идет очень большое падение производительности (конечно, если ее в цикле выполнять). Переписав алгоритмы так, чтобы память для того же StringBuilder'a выделялась до входа в функцию (или в цикл) получил в 2-3 раза увеличенную производительность

Sergey Rozovik комментирует...

> получил в 2-3 раза увеличенную производительность
Ну кто бы спорил... :)
Речь шла немного о другом. А именно, о том, почему сторки сделаны immutable и о влиянии этого факта на производительность. Заметьте, MSDN .Net не рекомендует использовать StrungBuilder, когда необходимо выполнить конкатенацию двух, трех строк, просто потому, что без него будет быстрее.

Анонимный комментирует...

"Заметьте, MSDN .Net не рекомендует использовать StrungBuilder, когда необходимо выполнить конкатенацию двух, трех строк, просто потому, что без него будет быстрее"

все зависит от того - где именно идет конкатенация. Например, в случае, если конкатенация двух-трех строк выполняется в цикле (и много раз), эффективнее использовать StringBuilder, но! созданный 1 раз, а перед каждой конкатенацией вызывать Clear()

Особенно эффективно сделать такую временную StringBuilder-переменную статической

А вообще - то, что модифицировать строки можно только через StringBuilder сильно мешает...

Sergey Rozovik комментирует...

>А вообще - то, что модифицировать строки можно только через StringBuilder сильно мешает...

Я вот как раз и пытаюсь донести мысль - что строки в .Net не модифицируются в принципе. Даже через StringBuilder - строки не модифицируются а создаются. Что происходит, когда мы в цикле выполняем что-то вроде string1 += string2; ? У нас в куче на каждой итерации выделяется новый объект строки под sting1, который становится объектом для сборщика мусора на следующей итерации. Создание на каждой итерации StringBuilder, копирование туда содержимого string1, а потом string2, а потом таки создание в кучи результирующей строки для хранения ее значения до следующей итерации - чувствуете разницу? Даже если использовать один и тот же StringBuilder для всего цикла - все равно более накладно.
Если возникает задача постоянно модифицировать содержимое одной строки, то можно использовать, например, массив char[]

Unknown комментирует...

Еще, правда не поручусь, на днях слышал интересную информацию.
string a = "test";
string b = "test";
string c = "test1";
Так вот слышал я о том, что a и b будут показывать на один и тот же объект.
Как мне кажется, с этим в принципе можно получить неприятные эффекты, например в многопоточном приложении при lock`е в кэше каком-то.

Fahrain комментирует...

"Так вот слышал я о том, что a и b будут показывать на один и тот же объект."

Тут все не так просто. Да, строки хранятся в специальной таблице - вроде бы типа hashtable. Но!

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

Насколько я знаю - во втором фреймворке от кеширования всех строк отказались.

В любом случае - если сравнивать _адреса_ объектов a и b - то они скорее всего будут одинаковыми. Это удобно тем, что в этом случае до сравнения содержимого дело даже и не доходит -> функция сравнения работает быстрее

P.S.: ни с какими неприятными побочными эффектами от такого поведения я не сталкивался.

Peter Nikouline комментирует...

"Строка target после его вызова остается пустой"

Данная проблема решается добавлением "out". Например:

protected static void TryToFill(out string target, string source)
{
target = source;
}
...

string s = "source";
string t = "";
TryToFill(out t, s);
Console.WriteLine("target = '{0}'", t);

Sergey Rozovik комментирует...

> Данная проблема решается добавлением "out".

Спасибо.
Я об этом не упомянул только потому, что мне казалось это очевидным.

Peter Nikouline комментирует...

"Я об этом не упомянул только потому, что мне казалось это очевидным".
Охотно верю

Iv комментирует...

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

Sergey Rozovik комментирует...

> по-моему довольно банальная тема

Я тоже так думал, но дело в том, что код с ошибкой, с которого все началось написал старший программист, который не первый год работает с .Net. Этот факт меня и подтолкнул поднять тему.
Повторение - мать учения :)

Iv комментирует...

специальную хештаблицу - забыл в кавычки взять.

А вообще согласен, бессистемный подход к фреймворку приводит как раз потом к тому, что приходится возвращаться к базовым вещам.

У нас в коде есть прикольные куски, где бъется имя файла, меняются расширения, это все через substring() и циклами до indexof(какой-то слеш) вместо использования класса Path. Вместо вызова File.WriteAllText, вижу код строк на 15, где стрингбилдер переводиться в строку, потом строка бъется по \r\n на массив строк, потом цикл по массиву с построчной записью в файл ну и т.д. и т.п.

Unknown комментирует...

2 Fahrain:
Спасибо за пояснение!

Анонимный комментирует...

Всё правильно про строки написано, но нерабочий код, который вынесен как причина - никакого отношения к строким или ссылочным типам не имеет. Просто код - идиотский. Тогда уж и для массива надо было бы написать

public static void Change(byte[] val)
{
val = null;
}

и ждать, что массив обnullится.

При этом

protected static void TryToFill(ref string target, ref string source)

будет работать правильно.