O wydajności w aplikacjach .NET cz. 3
W poprzednim odcinku cyklu O wydajności w aplikacjach .NET omawialiśmy mechanizm Opakowywania i Rozpakowywania (ang. boxing/unboxing), czyli przekształcania pewnych typów danych w typ bazowy (tu Object) i z powrotem. Dziś zajmiemy się kwestią bardziej przyziemną, doskonale znaną wszystkim programistom... Ale czy na pewno?
Operacje na ciągach znaków
Środowisko .NET Framework udostępnia nam typ danych System.String na potrzeby reprezentowania ciągów znaków. Klasa String zawiera chyba wszystko "co tygrysy lubią najbardziej" i jest naprawdę obszernie i dokładnie opisana na stronach MSDN. Taki stan rzeczy wynika z prostego faktu - nasze całe życie opiera się na słowie pisanym i reprezentowaniu znaków w dowolnym języku naturalnym. W wirtualnym świecie słowa i znaki także odgrywają wiodącą rolę. Toteż decydując się na wybór języka programowania (a jest ich kilka) wielu z nas swą decyzję opiera na dostępności, w ramach danej platformy programistycznej, bogatej gamy metod umożliwiających przetwarzanie zarówno pojedynczych znaków jak i ich dłuższych odpowiedników.
Platforma .NET wprowadza znaczny poziom abstrakcji. Nie wszystko co wydaje się z pozoru proste - okiem początkującego programisty - musi mieć równie prostą implementację "za kulisami dotnetu". Obiekt klasy String w kodzie zarządzanym to nic innego jak pewna kolekcja obiektów klasy System.Char reprezentujących dany łańcuch znaków. Z chwilą utworzenia instancji (czyt. utworzenia obiektu) klasy String, wartość tego obiektu nie będzie mogła już ulec zmianie, jest niezmienna (ang. immutable)!.
Przeczytaj ponownie poprzednie zdanie
Wartości obiektów klasy String są wyłącznie do odczytu. Właśnie ta cecha obiektów reprezentujących ciągi znaków i jej powszechna nieznajomość jest powodem poważnych strat w wydajności kodu zarządzanego. Ale każdy popełnia błędy, dlatego spróbujmy czym prędzej naprawić własne.
- Każdorazowa operacja na obiekcie klasy String powodująca zmianę wartości przechowywanego w nim łańcucha znaków sprawia, że: pamięć, w której przechowywany jest obecny ciąg znaków (wartość obiektu), zostanie porzucona
- konieczna będzie alokacja nowego bloku pamięci zdolnego przechować nową wartość obiektu
- ponieważ typ String jest typem referencyjnym, a wartości obiektów typu referencyjnego przechowywane są na stercie, stare, nieużywane już łańcuchy znaków będą podlegały - z czasem - odśmiecaniu (ang. garbage collection)
Analogicznie do poprzednich rozważań, pojedyncza operacja tego rodzaju nam nie zaszkodzi (z wyjątkiem naprawdę długich ciągów). W przypadkach, w których zarówno same ciągi znaków jak również ich ilość jest niewielka i z góry znana, śmiało wykorzystuj operator + w celu ich łączenia. Tu wykonamy tę operację wielokrotnie, w pętli, przy użyciu "standardowych praktyk" jak i zoptymalizowanego rozwiązania.
using System; using System.Text; using System.ComponentModel; using System.Security; using System.Runtime.InteropServices; namespace TestPerformance3 { class Program { static void Main(string[] args) { // Implementacja klasy PerformanceCounter dostępna w pierwszej części cyklu PerformanceCounter counter = new PerformanceCounter(); // Ilość iteracji ograniczona. Nie chcemy przecież wyjątku OutOfMemoryException int iterations = 100000; string str = "tekst"; string result = ""; // Domyślna pojemność początkowa StringBuilder sbResult = new StringBuilder(); // Pojemność StringBuilder jest ograniczona, jednak w pętlach nie będziemy sprawdzali przekroczenia zakresu StringBuilder sbCapacityResult = new StringBuilder(iterations * str.Length <= Int32.MaxValue ? iterations * str.Length : Int32.MaxValue); counter.Start(); for (int i = 0; i < iterations; i++) { result += str; } counter.Stop(); Console.WriteLine("Operator +:"); // Każda kolejna iteracja była bardziej kosztowna od poprzedniej Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); Console.WriteLine(); counter.Start(); for (int i = 0; i < iterations; i++) { sbResult.Append(str); } counter.Stop(); Console.WriteLine("StringBuilder.Append:"); Console.WriteLine("StringBuilder.Capacity = {0}", sbResult.Capacity); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); Console.WriteLine(); counter.Start(); for (int i = 0; i < iterations; i++) { sbCapacityResult.Append(str); } counter.Stop(); Console.WriteLine("StringBuilder.Append, wersja 2:"); Console.WriteLine("StringBuilder.Capacity = {0}", sbCapacityResult.Capacity); Console.WriteLine("{0:F3} ns - pojedyncza iteracja", counter.ElapsedNanoseconds / iterations); Console.WriteLine("{0:F3} ms - całość", counter.ElapsedMiliseconds); Console.ReadKey(); } } }
Dla wielu, następujących po sobie konkatenacji (piękne słowo) lepszym rozwiązaniem jest wykorzystanie obiektu klasy StringBuilder, który traktować możemy jak akumulator (czyt. bufor).
Domyślna pojemność instancji StringBuilder to 16 znaków a jej przekroczenie skutkować będzie powołaniem do życia nowego obiektu będącego w stanie pomieścić nowy łańcuch tekstowy (dawniej pojemność wzrastała dwukrotnie). Tym samym oszczędzimy na wielu, dla nas niewidocznych, choć zupełnie niepotrzebnych operacjach związanych ze zwalnianiem, odśmiecaniem i przydzielaniem zasobów. StringBuilder nie jest typem pochodnym klasy String, lecz jak każdy inny dziedziczy po typie bazowym metodę ToString, którą w dowolnej chwili możemy wykorzystać w celu przekształcenia typu StringBuilder na typ String. Pamiętajmy, że im początkowa pojemność (ustalana w wywołaniu konstruktora klasy) będzie bliższa rzeczywiście wykorzystywanej w środowisku produkcyjnym - tym więcej zyskamy. Dokonać tego możemy w dwojaki sposób, bądź jawnie definiując zakres i wielkość wprowadzanych danych bądź mierząc średnie zużycie zasobów z wykorzystaniem narzędzi typu Profiler, ale o tym, miejmy nadzieję, wspomnę w przyszłości. Powodzenia!
P.S. Tym razem zaprezentuję wyniki.
Operator +: 231736,764 ns - pojedyncza iteracja 23173,676 ms - całość
StringBuilder.Append: StringBuilder.Capacity = 504192 12,695 ns - pojedyncza iteracja 1,269 ms - całość
StringBuilder.Append, wersja 2: StringBuilder.Capacity = 500000 12,728 ns - pojedyncza iteracja 1,273 ms - całość