четверг, 13 марта 2014 г.

Строки в .Net, что в них интересного

Всем, наверное, известно, что строки в .Net хоть и являются ссылочным типом, неизменяемы, что делает их поведение отчасти похожим на поведение значимых типов. То есть, вот в этом коде мы создали не две строки, а три:

            
            string s1 = "te";
            string s2 = "st";
            s1 = s1 + s2;

В первой строке создается первая строка, во второй вторая, а в третьей - третья, адрес которой потом присваевается первой. В этом примере создание еще одной строковой переменной "за кулисами" кроме лишней  траты памяти ничем не грозит, но вот вам еще один пример. Поинтереснее.

            string s1 = "hello";
            string s2 = s1;

            Console.WriteLine(s1);            
            Console.WriteLine(s2);

            Console.WriteLine();
                        
            s1 = "world";
            Console.WriteLine(s1);            
            Console.WriteLine(s2);

В этом примере неизменяемость строк приводит к не очень очевидным результатам. Как думаете, что будет выведено на экран? Правильный ответ:

            hello
            hello

            world
            hello

 И еще, чтобы окончательно запутать бедных программистов, тип string переопределяет оператор ==, чтобы он сравнивал не ссылки, а содержимое объектов. Что, впрочем, для строк очень часто одно и то же, что и сравнение по ссылке.
 Так почему строки сделали неизменяемыми, спросите вы. Причин несколько.



Во-первых, это сделано для того, чтобы строки были потокобезопасными. А так как строки в CLR хранятся как обычные BSTR-строки (четыре байта длинны, затем сама строка по два байта на символ и два нуля в конце) то их легко можно передавать в неуправляемый код как WCHAR*.
[Хотя, на самом деле, строки в .Net занимают 18+кол-во символов*2 (.Net версии до 4) или 14+кол-во символов*2 (.Net 4+) с округлением до 4 байт на x86 и 30+кол-во символов*2 (.Net версии до 4) или 26+кол-во символов*2 (.Net 4+) с округлением до 8 байт на x64 ]

Во-вторых строки часто используются в качестве ключей в хешах, а если строки неизменяемы, то вы не можете изменить уже созданный ключ, даже случайно. В-третьих, это скорость и эффективность операций над строками. Например, при конкатенации строк просто выделяется участок памяти равный размеру первой и второй строк, в который строки копируются.

И еще одна важная особенность строк в .Net ради которой строки сделаны неизменяемыми - интернирование. Что это такое хорошо проиллюстрирует следующий пример:

            string s1 = "world";
            string s3 = "wo" + "rld";
            Console.WriteLine(object.ReferenceEquals(s1, s3));  
            // True, s1 и s3 указывают на один и тот объект в памяти.          


Правда, интернирование работает только во время компиляции. Во время выполнения строки автоматически не интернируются. Что, впрочем, не мешает вам сделать это самостоятельно.
            string s4 = "wo";
            string s5 = "rld";
            string s6 = s4 + s5;
            Console.WriteLine(object.ReferenceEquals(s1, s6));
            //False s1 и s6 указывают на разные объекты в памяти.

            s6 = string.Intern(s4 + s5);
            Console.WriteLine(object.ReferenceEquals(s1, s6));
            // True, s1 и s3 указывают на один и тот объект в памяти.  

Смысл интернирования в оптимизации во-первых, памяти, а во-вторых, неэффективного кода как в первой части примера выше.

Ну и последнее, по поводу StringBuilder, который частенько рекомендуют использовать в коде чуть ли не везде вместо string. Давайте проверим. Возьмем вот такой код:
            Stopwatch timer = new Stopwatch();
            long mem = GC.GetTotalMemory(true);
            timer.Start();
            string testString = "";
            for (int i = 0; i < 10000; i++)
            {
                testString += i.ToString();
            }
            timer.Stop();
            mem = GC.GetTotalMemory(true) - mem;
            Console.WriteLine(mem);
            Console.WriteLine(timer.Elapsed);

            timer.Reset();
            mem = GC.GetTotalMemory(true);
            timer.Start();
            StringBuilder sb = new StringBuilder();

            for (int i = 0; i < 10000; i++)
            {
                sb.Append(i.ToString());
            }
            
            string result = sb.ToString();
            sb.Clear();
            timer.Stop();
            mem = GC.GetTotalMemory(true) - mem;
            Console.WriteLine(mem);
            Console.WriteLine(timer.Elapsed);

У меня получились следующие результаты:
77808
00:00:00.1026275
80456
00:00:00.0015509

Как видим, память сборщиком мусора почистилась довольно эффективно, правда за счет его работы время выполнения увеличилось почти в 7 раз, что, конечно, не хорошо. А если попробовать то же самое, но не с 10000 строк, а с 10?
48
00:00:00.0000115
104
00:00:00.0000149

Картина, не сказать, что противоположная, но теперь у варианта со StringBuilder и памяти занято в два раза больше и время выполнения на 40% больше.

Так что, StringBuilder имеет смысл использовать только при работе с действительно большим количеством строк. Если же вам нужно сделать "Hello" + "World", то от его использования никакой пользы точно не будет.

Комментариев нет:

Отправить комментарий