Kod yazarken dikkat etmemiz gereken en önemli noktalardan biri gereksiz memory kullanımından kaçınmak. Yanlış memory kullanımı dediğimizde aklımıza ilk gelen tiplerden biri de string tipi. String yapısı gereği immuatable bir tip olduğu için string üzerinde değişiklik yapmak istediğimizde, farklı stringleri birleştirmek istediğimizde vs.. yeni bir string yaratmamız gerekiyor. Daha öncesinde stringleri belirli bölgesinden kesmek istediğimizde de Substring metodunu kullanarak yeni bir stringin yaratılmasına sebep olabiliyorduk. Ancak yeni gelen StringSegment veya Span tipleriyle yeni string yaratılmasından da kaçınmamız şu an mümkün.
Runtime esnasında elimizdeki belirli stringleri birleştirip yeni bir string yaratmak istediğimizde ve bu işlemi optimize bir şekilde yapmak istediğimizde kullanmamız gereken tip StringBuilder. Ancak StringBuilder string birleştirme operasyonlarını optimize bir şekilde yapsa da arka planda birleştirilecek olan stringleri bir bufferda sakladığını için ekstra heap allocationa neden olmakta. Bu buffer eklediğimiz stringlerin boyutuna göre de yerine göre resize edilmekte ve değerler yeni buffera kopyalanmakta. Bu da tabi ki performans kritik senaryolarda bir yük getirmekte. Bunu optimize etmek için eğer oluşturulacak olan stringin final uzunluğu biliniyorsa StringBuilder yaratılırken bir capacity belirtmek bizi en azından bufferın arka planda resize edilmesinden kurtaracaktır.
Bunun yanında özellikle sık çalışan kodlarda sürekli olarak StringBuilder nesnesi yaratmak arka planda aynı zamanda yeni buffer yaratılmasına neden olacaktır. Bu nedenle sürekli olarak buffer yaratılıp sonra GC tarafından temizlenmesinin önüne geçmek için StringBuilderlar bir object pool içerisinde saklanıp gerektiği zaman pooldan alınıp iş bittiği zaman poola geri bırakılabilir(Object Pooling).
.NET Core 2.1 ile beraber string tipinin içerisine eklenen Create metoduyla arka planda buffer allocationa neden olmadan da etkin bir şekilde string yaratmak mümkün hale geldi. Bu metodu kullanırken de göreceğiniz üzere string parametre olarak geçtiğimiz delegate içerisinde mutable durumda. Create metodunu kullanabilmemiz için ilk şart oluşacak olan stringin uzunluğunu önceden biliyor olmak. Eğer önceden bilmiyorsak veya hesaplayamıyorsak bu metodu kullanmamız mümkün değil.
public static string Create<TState>(int length, TState state, System.Buffers.SpanAction<char, TState> action);
Create metodunun parametrelerine bakarsak,
- length: Oluşturulacak stringin uzunluğu.
- state: stringi yaratırken kullanacağımız ve delegate’e parametre olarak gelecek olan object.(Eğer birden fazla değişkeni parametre geçmemiz gerekirse tuple kullanabiliriz.)
- action: string yaratılırken çağrılacak olan delegate.
Bir kullanım örneği;
List<string> list = new List<string>() { "test", "test2" }; var str3 = string.Create(9, list, (c, state) => { int index = 0; state[0].AsSpan().CopyTo(c); index += state[0].Length; state[1].AsSpan().CopyTo(c.Slice(index)); });
Şimdi diyelim ki elimizde bir liste var ve bu listedeki her bir elemanı birleştirip string yaratmak istiyoruz. İlk kullanım örneği olduğu için bazı değerleri statik yaparak ilerliyoruz. Liste iki elemanlı olduğu için ve kod yazarken içerisindeki değerleri görebildiğimiz için oluşacak olan stringin final uzunluğunu tahmin etmemiz mümkün. Bu nedenle ilk parametreyi 9 olarak veriyoruz. İkinci parametre ise stringi yaratırken kullanacağımız state nesnesi. Bunun için yukarıda tanımlanan list’i parametre olarak geçiyoruz.
Son parametre olarak da stringi initialize eden delegate’i veriyoruz. Bu delegate’in ilk parametresi stringin arkasında saklanan char arrayi temsil eden bir Span. Bu parametreyi kullanarak stringi initialize edebileceğiz. İkinci parametre ise state parametresinin kendisi. Biz bu değişkeni zaten ikinci parametre olarak geçmiştik diyebilirsiniz. Ancak biz delegate içerisinde list değişkenini kullanırsak bu yeni bir allocationa neden olacağı için efektif olmayacaktır. Bu nedenle stringi yaratırken mutlaka state parametresini kullanarak ilerlememizde fayda var.
Delegate’in içeriğine baktığımızda ise ilk olarak listenin ilk elemanını kopyalıyoruz sonrasında ise ikinci elemanı kopyalıyoruz. Böylece baktığımızda stringleri tutmak için arkada bir buffer allocate etmeye gerek kalmıyor.
Daha generic çalışan bir implementasyon yaparsak.
List<string> list = new List<string>() { "test", "test2" }; var length = 0; for (int i = 0; i < list.Count; i++) { length += list[i].Length; } var str3 = string.Create(length, list, (c, state) => { int index = 0; for (int i = 0; i < state.Count; i++) { state[i].AsSpan().CopyTo(c.Slice(index)); index += state[i].Length; } });
Şimdi de son olarak ufak bir benchmarking yapalım ve aradaki farkı inceleyelim.
[MemoryDiagnoser] [Orderer(BenchmarkDotNet.Order.SummaryOrderPolicy.FastestToSlowest)] [MarkdownExporter] public class Benchmark { List<string> list; [Params(10, 100, 400)] public int Size { get; set; } [IterationSetup] public void Setup() { list = new List<string>(); for (int i = 0; i < Size; i++) list.Add("Test"); } [Benchmark] public string StringCreate() { var length = 0; for (int i = 0; i < list.Count; i++) { length += list[i].Length; } return string.Create(length, list, (c, state) => { int index = 0; for (int i = 0; i < state.Count; i++) { state[i].AsSpan().CopyTo(c.Slice(index)); index += state[i].Length; } }); } [Benchmark] public string StringBuilderDefault() { var builder = new StringBuilder(); for (int i = 0; i < list.Count; i++) { builder.Append(list[i]); } return builder.ToString(); } [Benchmark] public string StringBuilderInitialCapacity() { var capacity = 0; for (int i = 0; i < list.Count; i++) { capacity += list[i].Length; } var builder = new StringBuilder(capacity); for (int i = 0; i < list.Count; i++) { builder.Append(list[i]); } return builder.ToString(); } }
Çıktı;
Method | Size | Mean | Error | StdDev | Median | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|
StringCreate | 10 | 1.028 μs | 0.0303 μs | 0.0839 μs | 1.000 μs | – | – | – | 104 B |
StringBuilderDefault | 10 | 2.333 μs | 0.3873 μs | 1.1420 μs | 1.600 μs | – | – | – | 448 B |
StringBuilderInitialCapacity | 10 | 1.273 μs | 0.0291 μs | 0.0574 μs | 1.300 μs | – | – | – | 256 B |
StringCreate | 100 | 3.152 μs | 0.4089 μs | 1.2056 μs | 3.500 μs | – | – | – | 824 B |
StringBuilderDefault | 100 | 2.385 μs | 0.0512 μs | 0.0718 μs | 2.400 μs | – | – | – | 2280 B |
StringBuilderInitialCapacity | 100 | 3.216 μs | 0.4154 μs | 1.2116 μs | 2.400 μs | – | – | – | 1696 B |
StringCreate | 400 | 3.224 μs | 0.0647 μs | 0.0664 μs | 3.200 μs | – | – | – | 3224 B |
StringBuilderDefault | 400 | 19.884 μs | 1.4567 μs | 4.2492 μs | 20.500 μs | – | – | – | 7896 B |
StringBuilderInitialCapacity | 400 | 5.924 μs | 0.4355 μs | 1.2773 μs | 6.300 μs | – | – | – | 6496 B |
Gördüğümüz gibi Create metodu kullandığımızda hem kullanılan memory daha düşük hem de daha performanslı bir şekilde stringi yaratabiliyoruz. Performansla ilgili yazdığımız yazıda olduğu gibi burada da kullanımları kendi senaryolarınızla karşılaştırıp, benchmarking yapmanız ve ona göre karar vermeniz en doğrusu. Özellikle çok fazla çalışan ve string üretilen kodlarda kullanmak size büyük kazançlar sağlayabilir. Ancak çok sık çalışmayan yerlerde beklediğiniz faydayı da göremeyebilirsiniz.