İlkay İlknur

String.Create Metodu Nasıl Kullanılır ?

Temmuz 29, 2020

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<charTState> 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.

Şimdi gelin önce basit bir kullanım örneğiyle başlayalım.

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<char>. 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 { getset; }
 
    [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();
    }
}

Sonuçlara bakarsak...

MethodSizeMeanErrorStdDevMedianGen 0Gen 1Gen 2Allocated
StringCreate101.028 μs0.0303 μs0.0839 μs1.000 μs---104 B
StringBuilderDefault102.333 μs0.3873 μs1.1420 μs1.600 μs---448 B
StringBuilderInitialCapacity101.273 μs0.0291 μs0.0574 μs1.300 μs---256 B
StringCreate1003.152 μs0.4089 μs1.2056 μs3.500 μs---824 B
StringBuilderDefault1002.385 μs0.0512 μs0.0718 μs2.400 μs---2280 B
StringBuilderInitialCapacity1003.216 μs0.4154 μs1.2116 μs2.400 μs---1696 B
StringCreate4003.224 μs0.0647 μs0.0664 μs3.200 μs---3224 B
StringBuilderDefault40019.884 μs1.4567 μs4.2492 μs20.500 μs---7896 B
StringBuilderInitialCapacity4005.924 μs0.4355 μs1.2773 μs6.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. Ancak tabi ki her 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. Her yerde bu metodu kullanmak yerine ihtiyaç duyduğumuz doğru yerlerde kullanabiliyor olmak en güzeli.

Bir sonraki yazıda görüşmek üzere,