C# String API'si Ayrıntılı: Arama, Bölme, Birleştirme, Kırpma ve Değiştirme Method'ları
String'de string arayan IndexOf, StartsWith ve EndsWith varsayılan olarak culture'a duyarlıdır; char arayanlar, Contains, Replace ve Split ise ordinal çalışır, bu yüzden makine verisinde her zaman StringComparison.Ordinal vermek gerekir. Bookstore'da slug üretme ve ISBN normalizasyonu üzerinden Split seçeneklerini, Join ile += farkını, AsSpan'i, SearchValues ve CompositeFormat'ı ve .NET 11'in Rune overload'larını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; Rune overload'ları bölümündeki örnek .NET 11 SDK ile çalıştırılmıştır.
Önce Tahmin Et
Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) eski muhasebe sisteminden gelen dosyada kitap adı sabit genişlikli bir alanda duruyor ve boşluk yerine null karakterle (\0) doldurulmuş. Aşağıdaki üç satır ne yazdırır?
// Eski muhasebe sisteminden gelen sabit genişlikli alan, sonu null karakterle dolu.
string field = "Dune\0\0\0\0";
Console.WriteLine(field.IndexOf('\0'));
Console.WriteLine(field.IndexOf("\0"));
Console.WriteLine("Dune,,Emma,".Split(',').Length);
Çoğu kişi ilk ikisine 4, üçüncüsüne 2 ya da 3 der. Gerçek çıktı:
4
0
4
Aynı karakteri arıyoruz; char verince 4, string verince 0 dönüyor. Üçüncü satırda ise Split boş parçaları da sayıyor: "Dune", "", "Emma", "". Bu yazı bu tür farkların nereden geldiğini ve her gün yazdığın string method'larında hangi overload'u seçmen gerektiğini anlatıyor.
C# Temelleri serisinin "C# String'ler: Interpolation, StringBuilder ve Türkçe I Tuzağı" yazısı string'in immutable olduğunu ve temel method'ları tanıttı. Burada bir adım ileri gidiyoruz: arama, bölme, birleştirme, kırpma ve değiştirme method'larının overload'ları, varsayılanları ve tekrar eden işlerde allocation'ı azaltan .NET 8 API'leri. Regular expression bu serinin konusu değil; pattern ile arama ve doğrulama için Regular Expressions serisine bakabilirsin. Örneklerin tamamı serinin repo'sundaki Bookstore.Bcl.Samples console uygulamasında:
dotnet run --project samples/Bookstore.Bcl.Samples -- strings-search --culture en-US
Arama: IndexOf, Contains, StartsWith ve Varsayılan Karşılaştırma
String üzerinde arama yapan method'ların bir kısmı varsayılan olarak ordinal (karakterlerin sayısal değerlerini birebir karşılaştıran), bir kısmı ise culture'a duyarlı (linguistic) karşılaştırma yapar. Hangisinin hangisi olduğunu ezberlemek zor, çünkü ayrım method adına göre değil, overload'a göre değişiyor:
| Çağrı | Varsayılan karşılaştırma |
|---|---|
IndexOf(char), LastIndexOf(char), StartsWith(char) | Ordinal |
Contains(string), Replace(string, string), Split | Ordinal |
Equals, == | Ordinal |
IndexOf(string), LastIndexOf(string) | CurrentCulture |
StartsWith(string), EndsWith(string) | CurrentCulture |
string.Compare, CompareTo | CurrentCulture |
Culture'a duyarlı karşılaştırmayı .NET 5'ten beri her platformda ICU (International Components for Unicode; Unicode karşılaştırma ve yerel ayar verisini sağlayan library) yapıyor. ICU'nun kurallarında bazı karakterlerin ağırlığı sıfırdır: null karakter, soft hyphen (U+00AD, web sayfasından kopyalanan metinde görünmeden gelen tire) ve zero-width karakterler karşılaştırmada yok sayılır. Aynı alanı ve kopyala-yapıştır ile gelmiş bir kitap adını farklı overload'larla arayalım:
string field = "Dune\0\0\0\0";
Row("IndexOf('\\0')", field.IndexOf('\0'));
Row("IndexOf(\"\\0\")", field.IndexOf("\0"));
Row("IndexOf(\"\\0\", Ordinal)", field.IndexOf("\0", StringComparison.Ordinal));
Row("Contains(\"\\0\")", field.Contains("\0"));
Row("Replace(\"\\0\", \"\")", $"[{field.Replace("\0", "")}]");
// U+00AD soft hyphen: görünmez, web sayfasından kopyalanan metinle gelir.
string pasted = "Middle\u00ADmarch";
Row("StartsWith(\"Middlemarch\")", pasted.StartsWith("Middlemarch"));
Row("StartsWith(..., Ordinal)", pasted.StartsWith("Middlemarch", StringComparison.Ordinal));
Row("Equals(\"Middlemarch\")", pasted.Equals("Middlemarch"));
Row("Contains(\"Middlemarch\")", pasted.Contains("Middlemarch"));
static void Row(string label, object value) => Console.WriteLine($"{label,-26}: {value}");
IndexOf('\0') : 4
IndexOf("\0") : 0
IndexOf("\0", Ordinal) : 4
Contains("\0") : True
Replace("\0", "") : [Dune]
StartsWith("Middlemarch") : True
StartsWith(..., Ordinal) : False
Equals("Middlemarch") : False
Contains("Middlemarch") : False
IndexOf("\0") culture'a duyarlı aradığı için sıfır ağırlıklı karakteri "her yerde var" kabul ediyor ve 0 dönüyor. Contains ve Replace ordinal olduğu için doğru çalışıyor. İkinci grupta daha sinsi bir durum var: StartsWith görünmez tireyi yok sayıp True diyor, aynı string için Equals ve Contains False diyor. Kodun bir yerinde StartsWith ile "bu kitap zaten var" deyip başka bir yerinde dictionary'de bulamazsan sebebi budur.
Aynı programı invariant globalization mode'da (DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1, ICU yüklenmez) çalıştırınca culture'a duyarlı çağrılar da ordinal davranır ve IndexOf("\0") 4, StartsWith("Middlemarch") False döner. Yani aynı kod container image'ına göre farklı sonuç verebilir; bunu serinin 3. yazısında ayrıntılı göreceğiz.
Büyük-küçük harf duyarsız aramada culture farkı daha da görünür olur. "THE HOBBIT" içinde "hobbit" arayan kodu üç culture'da çalıştırdım:
string title = "THE HOBBIT";
Console.WriteLine($"{CultureInfo.CurrentCulture.Name,-6} CurrentCultureIgnoreCase: {title.IndexOf("hobbit", StringComparison.CurrentCultureIgnoreCase)}");
Console.WriteLine($"{CultureInfo.CurrentCulture.Name,-6} OrdinalIgnoreCase : {title.IndexOf("hobbit", StringComparison.OrdinalIgnoreCase)}");
Console.WriteLine($"{CultureInfo.CurrentCulture.Name,-6} ToLower() : {title.ToLower()}");
tr-TR CurrentCultureIgnoreCase: -1
tr-TR OrdinalIgnoreCase : 4
tr-TR ToLower() : the hobbıt
en-US CurrentCultureIgnoreCase: 4
en-US OrdinalIgnoreCase : 4
en-US ToLower() : the hobbit
CurrentCultureIgnoreCase: 4
OrdinalIgnoreCase : 4
ToLower() : the hobbit
Türkçede I harfinin küçüğü ı olduğu için tr-TR altında "HOBBIT" ile "hobbit" eşleşmiyor. Son üç satırın başı boş, çünkü invariant culture'ın adı boş string. Kural basit: kitap adı, ISBN (International Standard Book Number; kitaplara verilen uluslararası standart numara), sipariş numarası, dosya yolu, HTTP header'ı gibi makine verisinde StringComparison.Ordinal ya da OrdinalIgnoreCase kullan. Kullanıcıya gösterilen metni sıralarken ve kullanıcının dilinde arama yaparken culture'lı karşılaştırma doğru seçim olabilir, ama o zaman da culture'ı açıkça ver.
Split: RemoveEmptyEntries ve TrimEntries
Kurumsal müşteriler sipariş formuna kitap adlarını virgülle ayrılmış tek bir alana yazıyor. Kullanıcı girdisi olduğu için boşluk ve fazladan virgül eksik olmuyor:
string input = "Dune, Emma,, The Hobbit , ";
Show("Split(',')", input.Split(','));
Show("RemoveEmptyEntries", input.Split(',', StringSplitOptions.RemoveEmptyEntries));
Show("TrimEntries", input.Split(',', StringSplitOptions.TrimEntries));
Show("Remove | Trim", input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
Show("count: 2", "BS-2026-0001234".Split('-', 2));
Show("string separator", "Dune -- Emma -- Middlemarch".Split(" -- "));
Show("any of ; and |", "Dune;Emma|Middlemarch".Split([';', '|']));
Split(',') 5 -> ["Dune" | " Emma" | "" | " The Hobbit " | " "]
RemoveEmptyEntries 4 -> ["Dune" | " Emma" | " The Hobbit " | " "]
TrimEntries 5 -> ["Dune" | "Emma" | "" | "The Hobbit" | ""]
Remove | Trim 3 -> ["Dune" | "Emma" | "The Hobbit"]
count: 2 2 -> ["BS" | "2026-0001234"]
string separator 3 -> ["Dune" | "Emma" | "Middlemarch"]
any of ; and | 3 -> ["Dune" | "Emma" | "Middlemarch"]
Tek başına RemoveEmptyEntries yalnız uzunluğu sıfır olan parçaları atar; " " boş sayılmaz ve listede kalır. .NET 5 ile gelen TrimEntries her parçayı kırpar ama kırpılınca boşalan parçayı silmez. İkisini birlikte verdiğinde önce kırpma, sonra boşları atma yapılır ve elinde üç temiz kitap adı kalır. Kullanıcı girdisini bölerken neredeyse her zaman istediğin bu kombinasyondur.
count parametresi en fazla kaç parça üretileceğini söyler; son parça geri kalan her şeyi taşır. Sipariş numarasının yalnız ön ekini ayırmak için kullanışlı. Split string ayırıcı ve birden çok karakter de kabul eder. Bir şeyi bilmesi mümkün değil: tırnak içindeki virgül. "Lovelace, Ada" gibi bir değeri olan gerçek CSV (Comma-Separated Values; değerleri ayırıcıyla sıralayan düz metin formatı) satırını Split ile bölemezsin. Bu konu .NET'te I/O İşlemleri serisinin "CSV ile Çalışmak" yazısında.
Birleştirmek: Join, Concat, StringBuilder ve string.Create
String immutable olduğu için döngüde += her adımda öncekinin tamamını kopyalayan yeni bir string üretir. Bunu sezgiyle değil, sayıyla görmek için bin kitap adını üç farklı yolla birleştirip GC.GetAllocatedBytesForCurrentThread ile ayrılan belleği ölçtüm:
string[] titles = Enumerable.Range(1, 1_000).Select(i => $"Book {i:D4}").ToArray();
long before = GC.GetAllocatedBytesForCurrentThread();
string csv = "";
foreach (string title in titles)
csv += title + ",";
long concatLoop = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
var builder = new StringBuilder();
foreach (string title in titles)
builder.Append(title).Append(',');
string built = builder.ToString();
long stringBuilder = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
string joined = string.Join(',', titles);
long join = GC.GetAllocatedBytesForCurrentThread() - before;
+= in a loop : 10,036,000 bytes, length 10000
StringBuilder : 53,200 bytes, length 10000
string.Join : 20,024 bytes, length 9999
Sonuç 10.000 karakterlik bir string; += bunun için yaklaşık 10 MB ayırıyor, StringBuilder 53 KB, string.Join 20 KB. Join en azı ayırıyor, çünkü önce toplam uzunluğu hesaplayıp tek seferde doğru boyda string oluşturuyor. Ayrıca sona fazladan ayırıcı koymadığı için uzunluk 9999. Elinde bir collection varsa string.Join, parçaları koşullara göre ekliyorsan StringBuilder kullan. Az sayıda parçayı yan yana koymak için string.Concat ya da interpolation yeterli; derleyici zaten onları tek bir çağrıya çevirir.
Uzunluğu baştan bilinen bir string için bir seçenek daha var: string.Create. Sana string'in kendi belleğini bir Span<char> olarak verir, sen doldurursun; ara buffer olmaz:
int orderNo = 1234;
string viaCreate = string.Create(10, orderNo, static (span, number) =>
{
"BS-".CopyTo(span);
number.TryFormat(span[3..], out _, "D7", CultureInfo.InvariantCulture);
});
Console.WriteLine(viaCreate);
BS-0001234
Şunu da belirtelim: bu örnekte $"BS-{orderNo:D7}" da tek bir string ayırır; C# 10'dan beri interpolation, havuzdan alınmış bir buffer'a yazıp sonunda tek string üretiyor. string.Create'in fark yarattığı yer, karakterleri kendin hesapladığın durumlar: hex kodlama, maskeleme, özel bir sıralama. static lambda ve state parametresi closure allocation'ını da önler. Span'ler ve allocation'sız parse C# İleri Seviye serisinin "C#'ta Span<T> ve Memory<T>" yazısında anlatılıyor.
Kırpma, Doldurma ve Değiştirme
Trim ailesinin parametresiz hâli Unicode'un boşluk saydığı her karakteri (boşluk, tab, satır sonu, non-breaking space) atar. Karakter listesi verirsen yalnız onları atar:
Console.WriteLine($"[{" Dune \t\r\n".Trim()}]");
Console.WriteLine($"[{"--Dune..".Trim('-', '.')}]");
Console.WriteLine($"[{"/books/dune/".TrimEnd('/')}]");
Console.WriteLine($"[{"000123".TrimStart('0')}]");
Console.WriteLine($"[{"123".PadLeft(7, '0')}]");
Console.WriteLine($"[{"Dune".PadRight(12)}|{"Emma".PadLeft(8)}]");
string note = "Gift wrap, GIFT card, gift note";
Console.WriteLine(note.Replace("gift", "present"));
Console.WriteLine(note.Replace("gift", "present", StringComparison.OrdinalIgnoreCase));
Console.WriteLine("978-0-441-17271-9".Replace("-", ""));
Console.WriteLine("Line1\r\nLine2\nLine3".ReplaceLineEndings(" | "));
Console.WriteLine("BS0001234".Insert(2, "-"));
Console.WriteLine("BS-0001234-DRAFT".Remove(10));
Console.WriteLine("Dune (Ace, 1990)".Remove(4, 12));
[Dune]
[Dune]
[/books/dune]
[123]
[0000123]
[Dune | Emma]
Gift wrap, GIFT card, present note
present wrap, present card, present note
9780441172719
Line1 | Line2 | Line3
BS-0001234
BS-0001234
Dune
Dikkat edilecek birkaç nokta var. Trim('0') bir "baştaki sıfırları sil" method'u değildir; "000" girdisinden boş string çıkar, sayıya çevireceksen int.Parse zaten baştaki sıfırları kabul eder. Replace(string, string) ordinal ve büyük-küçük harfe duyarlıdır; ilk satırda yalnız küçük harfli "gift" değişti. Büyük-küçük harf duyarsız değiştirme için .NET Core 2.0'dan beri StringComparison alan overload var. Farklı işletim sistemlerinden gelen satır sonlarını tek tipe çevirmek için .NET 6 ile gelen ReplaceLineEndings \r\n, \n, \r ve Unicode satır ayırıcılarının hepsini tanır. PadLeft ve PadRight console tablolarında işe yarar; ama sayıyı sıfırla doldurmak için format string (D7) daha doğru, çünkü negatif sayılarda işareti doğru yere koyar. Format string'leri bir sonraki yazının konusu.
Substring Yerine Range ve AsSpan
Substring her çağrıda yeni bir string ayırır. Okunabilirlik için C# 8'in range syntax'ı, allocation'dan kaçınmak için AsSpan var:
string orderNumber = "BS-2026-0001234";
string year1 = orderNumber.Substring(3, 4);
string year2 = orderNumber[3..7];
ReadOnlySpan<char> yearSpan = orderNumber.AsSpan(3, 4);
string last = orderNumber[^7..];
long before = GC.GetAllocatedBytesForCurrentThread();
int sum = 0;
for (int i = 0; i < 10_000; i++)
sum += int.Parse(orderNumber.Substring(8));
long withSubstring = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10_000; i++)
sum += int.Parse(orderNumber.AsSpan(8));
long withSpan = GC.GetAllocatedBytesForCurrentThread() - before;
2026 2026 2026 0001234
Substring + int.Parse : 400,928 bytes
AsSpan + int.Parse : 0 bytes
orderNumber[3..7] derlendiğinde yine Substring çağrısına dönüşür, yani allocation açısından fark yoktur; kazanç okunabilirliktir ([^7..] "son yedi karakter" demek). AsSpan ise string'in belleğine bakan bir pencere döner ve hiç allocation yapmaz. int.Parse, decimal.Parse, Guid.Parse ve DateTime.ParseExact gibi method'ların .NET Core 2.1'den beri ReadOnlySpan<char> overload'u var. On bin parse'ta 400 KB çöp, saniyede binlerce satır işleyen bir import job'ında GC'nin (garbage collector; kullanılmayan belleği toplayan runtime bileşeni) daha sık çalışması demek.
Bookstore: Başlıktan Slug, Girdiden ISBN
Katalogdaki her kitabın URL'i başlıktan üretilen bir slug ile bitiyor. İlk akla gelen çözüm ToLower().Replace(" ", "-"). Türkçe başlıklarla neler olduğuna bakalım:
foreach (string title in titles)
{
Console.WriteLine($"tr-TR naive : {title.ToLower(tr).Replace(" ", "-")}");
Console.WriteLine($"en-US naive : {title.ToLower(en).Replace(" ", "-")}");
Console.WriteLine($"Slug : {Slug.FromTitle(title)}");
}
tr-TR naive : kürk-mantolu-madonna
en-US naive : kürk-mantolu-madonna
Slug : kurk-mantolu-madonna
tr-TR naive : ince-memed
en-US naive : ince-memed
Slug : ince-memed
tr-TR naive : ıstanbul-hatıralar-ve-şehir
en-US naive : istanbul-hatiralar-ve-şehir
Slug : istanbul-hatiralar-ve-sehir
tr-TR naive : the-hobbit:-or-there-and-back-again
en-US naive : the-hobbit:-or-there-and-back-again
Slug : the-hobbit-or-there-and-back-again
tr-TR naive : --dune----part-1-
en-US naive : --dune----part-1-
Slug : dune-part-1
Yönetim panelinde başlığı büyük harfle giren bir editör, sunucunun culture'ına göre ıstanbul ya da istanbul slug'ı üretiyor; aynı kitabın iki farklı URL'i olabiliyor. Doğru çözüm iki adım: Unicode normalizasyonuyla harfi ve üzerindeki işareti ayırmak, sonra yalnız ASCII (temel Latin harfleri ve rakamlardan oluşan 128 karakterlik set) harf ve rakamları tutmak:
public static string FromTitle(string title, int maxLength = 80)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
// FormD "ü"yü "u" + U+0308'e (birleşen iki nokta) ayırır; işaret aşağıda atılır.
string decomposed = title.Normalize(NormalizationForm.FormD);
var slug = new StringBuilder(Math.Min(decomposed.Length, maxLength));
bool pendingDash = false;
foreach (char c in decomposed)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
continue;
// Noktasız ı'nın ayrıştırılmış hâli yok, elle eşliyoruz.
char lower = c == 'ı' ? 'i' : char.ToLowerInvariant(c);
if (char.IsAsciiLetterOrDigit(lower))
{
int needed = pendingDash && slug.Length > 0 ? 2 : 1;
if (slug.Length + needed > maxLength)
break;
if (needed == 2)
slug.Append('-');
slug.Append(lower);
pendingDash = false;
}
else
{
pendingDash = true;
}
}
return slug.ToString();
}
NormalizationForm.FormD "ü"yü "u" ve birleşen iki nokta işaretine, "ş"yi "s" ve çengele, "İ"yi "I" ve üst noktaya ayırır; NonSpacingMark kategorisindeki işaretleri atınca geriye ASCII harf kalır. Türkçenin tek istisnası ı: Unicode'da ayrıştırılmış bir karşılığı yok, o yüzden elle eşliyoruz. ToLowerInvariant culture'dan bağımsız olduğu için sunucunun ayarı sonucu değiştirmez. Harf ya da rakam olmayan her şey tek bir tireye iner, baştaki ve sondaki tireler hiç yazılmaz. maxLength sınırı kelimenin ortasında kesebilir (saatleri-ayarlama-en); URL için bu kabul edilebilir, istersen son tireye kadar geri gidebilirsin. Almanca "ß" ya da Danca "ø" gibi ayrışmayan harfler bu kodda düşer; çok dilli bir katalogda bunlar için de bir eşleme tablosu gerekir.
İkinci problem ISBN. Kurumsal müşteri sipariş Excel'ine ISBN'i her türlü yazıyor: "ISBN 978-0-441-17271-9", " isbn: 978 0441 172719 ", "9780441172719". Hepsini 13 haneye indirip kontrol hanesini doğrulayan method:
private static readonly SearchValues<char> Separators = SearchValues.Create(" -");
public static bool TryNormalize(string? input, [NotNullWhen(true)] out string? isbn13)
{
isbn13 = null;
if (string.IsNullOrWhiteSpace(input))
return false;
ReadOnlySpan<char> span = input.AsSpan().Trim();
if (span.StartsWith("ISBN", StringComparison.OrdinalIgnoreCase))
span = span[4..].TrimStart(": ");
Span<char> digits = stackalloc char[13];
int count = 0;
foreach (char c in span)
{
if (Separators.Contains(c))
continue;
if (!char.IsAsciiDigit(c) || count == digits.Length)
return false;
digits[count++] = c;
}
if (count != digits.Length || !HasValidCheckDigit(digits))
return false;
isbn13 = new string(digits);
return true;
}
// ISBN-13: haneler sırayla 1 ve 3 ile çarpılır, toplam 10'un katı olmalı.
private static bool HasValidCheckDigit(ReadOnlySpan<char> digits)
{
int sum = 0;
for (int i = 0; i < digits.Length; i++)
sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
return sum % 10 == 0;
}
"ISBN 978-0-441-17271-9" -> 9780441172719
" isbn: 978 0441 172719 " -> 9780441172719
"9780441172719" -> 9780441172719
"ISBN 978-0-441-17271-8" -> invalid
"978-0-441-17271" -> invalid
"ISBN 978-0-441-17271-9X" -> invalid
"" -> invalid
null -> invalid
Method'un hiçbir adımında ara string yok: Trim, StartsWith ve TrimStart span üzerinde çalışıyor, haneler stack'teki 13 karakterlik buffer'a yazılıyor ve tek allocation sonuçtaki new string(digits). Ön ek kontrolünde OrdinalIgnoreCase kullandık, çünkü "isbn" bir dil kelimesi değil, sabit bir etiket. char.IsDigit yerine char.IsAsciiDigit seçtik: IsDigit Arapça-Hint rakamları gibi Unicode rakamlarına da true döner, sonra c - '0' anlamsız bir sayı üretir. Kontrol hanesi yanlış olan dördüncü girdi, eksik haneli beşinci girdi ve sonunda harf olan altıncı girdi reddediliyor. Tireleri doğru yere geri koymak ise bu kadar basit değil: ISBN'deki grup ve yayıncı bölümlerinin uzunluğu ülkeye ve yayıncıya göre değişiyor, bunun için ISBN ajansının aralık tablosu gerekir.
Yeni API'ler: SearchValues, CompositeFormat ve Rune Overload'ları
SearchValues (.NET 8)
Yukarıdaki Separators alanı .NET 8 ile gelen SearchValues<char> tipinde. IndexOfAny(char[]) her çağrıda karakter setini yeniden inceler; SearchValues.Create ise set için en uygun arama stratejisini (ASCII bitmap, vektörize karşılaştırma) bir kez hesaplar ve static readonly alanda saklanınca her aramada tekrar kullanılır. Arama kutusuna gelen sorguda istenmeyen karakter var mı diye bakan bir örnek:
private static readonly SearchValues<char> UnsafeSearchChars = SearchValues.Create("<>\"'%;()&+");
foreach (string query in queries)
{
int index = query.AsSpan().IndexOfAny(UnsafeSearchChars);
Console.WriteLine($"{query,-18} IndexOfAny = {index,2}, ContainsAny = {query.AsSpan().ContainsAny(UnsafeSearchChars)}");
}
SearchValues<string> stopWords = SearchValues.Create(["the", "and", "of"], StringComparison.OrdinalIgnoreCase);
string title = "The Lord of the Rings";
Console.WriteLine($"First stop word at {title.AsSpan().IndexOfAny(stopWords)}");
dune IndexOfAny = -1, ContainsAny = False
dune<script> IndexOfAny = 4, ContainsAny = True
emma's IndexOfAny = 4, ContainsAny = True
middlemarch 1871 IndexOfAny = -1, ContainsAny = False
First stop word at 0
SearchValues<string> .NET 9 ile geldi ve birden çok kelimeyi tek geçişte arar; yalnız Ordinal ve OrdinalIgnoreCase kabul eder. Bu örnek bir girdi kontrolü; XSS (Cross-Site Scripting; sayfaya zararlı script enjekte etme) koruması değildir. Çıktıyı encode etmek ayrı bir iş ve Web Güvenliği serisinde anlatılıyor.
CompositeFormat (.NET 8)
İkinci .NET 8 API'si CompositeFormat. string.Format("{0} x {1:N2}", ...) her çağrıda format string'ini baştan parse eder. Aynı format döngüde binlerce kez kullanılıyorsa onu bir kez parse edip saklayabilirsin:
private static readonly CompositeFormat InvoiceLine = CompositeFormat.Parse("{0,-12}{1,3} x {2,10:N2} = {3,10:N2}");
CultureInfo tr = CultureInfo.GetCultureInfo("tr-TR");
foreach (var line in lines)
Console.WriteLine(string.Format(tr, InvoiceLine, line.Title, line.Quantity, line.Price, line.Quantity * line.Price));
Console.WriteLine($"Arguments expected: {InvoiceLine.MinimumArgumentCount}");
Dune 2 x 349,90 = 699,80
Middlemarch 1 x 1.249,50 = 1.249,50
Arguments expected: 4
FormatException: Input string was not in a correct format. Failure to parse near offset 8. Format item ends prematurely.
Son satır CompositeFormat.Parse("{0} x {1") çağrısından geliyor: format hatası, string'i kullandığın anda değil, parse ettiğin anda yakalanıyor. static readonly alan olduğu için bu, uygulama açılırken olur. MinimumArgumentCount da format'ın kaç argüman beklediğini söyler; test yazarken işe yarar. Format string'inin içindeki ,-12 ve :N2 kısımlarını bir sonraki yazıda ayrıntılı göreceğiz.
Rune Overload'ları (.NET 11)
char 16 bitlik bir kod birimidir (UTF-16; .NET string'lerinin bellekte kullandığı Unicode kodlaması); emoji ve bazı nadir karakterler iki char (surrogate pair) kaplar. .NET 10'da bir rafı etiketleyen emojiyi Trim ile atmaya çalışırsan derleyici izin vermez:
string shelfLabel = "📚 Dune 📚";
Console.WriteLine(shelfLabel.Trim('📚'));
Program.cs(2,35): error CS1012: Too many characters in character literal
Tek bir Unicode karakterini temsil eden tip .NET Core 3.0'dan beri System.Text.Rune. .NET 11, string'in arama ve düzenleme method'larına Rune alan overload'lar ekledi: Contains, StartsWith, EndsWith, IndexOf, LastIndexOf, Replace, Split, Trim, TrimStart ve TrimEnd. Repo'daki Bookstore.Bcl.Net11 projesi .NET 11 SDK ister:
Rune books = new(0x1F4DA);
Console.WriteLine($"Length: {shelfLabel.Length} chars, {shelfLabel.EnumerateRunes().Count()} runes");
Console.WriteLine($"Contains(Rune) : {shelfLabel.Contains(books)}");
Console.WriteLine($"IndexOf(Rune) : {shelfLabel.IndexOf(books)}");
Console.WriteLine($"LastIndexOf : {shelfLabel.LastIndexOf(books)}");
Console.WriteLine($"Trim(Rune) : [{shelfLabel.Trim(books).Trim()}]");
Console.WriteLine($"Split(Rune) : {string.Join(" | ", "Dune📚Emma📚Middlemarch".Split(books, StringSplitOptions.None))}");
Console.WriteLine($"Replace(Rune, Rune): {shelfLabel.Replace(books, new Rune('*'))}");
Length: 10 chars, 8 runes
Contains(Rune) : True
IndexOf(Rune) : 0
LastIndexOf : 8
Trim(Rune) : [Dune]
Split(Rune) : Dune | Emma | Middlemarch
Replace(Rune, Rune): * Dune *
Length 10 diyor, çünkü iki emoji dört char tutuyor; LastIndexOf'un 8 dönmesi de char indeksi olduğu içindir. .NET 10'da aynı işi emojiyi string olarak verip (Contains("📚")) yapabilirdin; yeni overload'lar niyeti tipte gösteriyor ve tek karakter dışında bir şey verilmesini derleme anında engelliyor.
Üretimde Dikkat
Culture'a bağlı varsayılanları kod incelemesinde yakalamak zor; bunun için analyzer'lar var. Projeyi AnalysisLevel=latest-recommended ile derlediğimde bu yazının örnek dosyası için şu uyarılar geldi (kısaltılmış):
dotnet build -p:AnalysisLevel=latest-recommended samples/Bookstore.Bcl.Samples
StringDemos.cs(30,27): warning CA1310: The behavior of 'string.IndexOf(string)' could vary based on the current user's locale settings. Replace this call in 'Bookstore.Bcl.Samples.Strings.StringDemos.Guess()' with a call to 'string.IndexOf(string, System.StringComparison)'.
StringDemos.cs(46,44): warning CA1310: The behavior of 'string.StartsWith(string)' could vary based on the current user's locale settings. Replace this call in 'Bookstore.Bcl.Samples.Strings.StringDemos.Search()' with a call to 'string.StartsWith(string, System.StringComparison)'.
StringDemos.cs(48,47): warning CA1309: Use ordinal string comparison
Varsayılan ayarlarla bu kuralların hiçbiri uyarı vermiyor; projeyi temiz derlemek, kodun culture'dan bağımsız olduğu anlamına gelmiyor. CA1307, CA1309, CA1310 (string karşılaştırma) ve CA1304, CA1305, CA1311 (culture'a bağlı dönüşüm) kurallarını .editorconfig ya da WarningsAsErrors ile zorunlu hâle getirmek, Statik Analiz serisinin ".NET Analyzer'ları" yazısında anlatılıyor. Aynı derlemede CA1865 ve CA1847 de geldi: tek karakterlik string yerine char overload'unu öneriyorlar, çünkü char overload'u hem ordinal hem daha hızlı.
İkinci nokta veri kaynağı. Web formundan, Excel'den, eski sistemlerden gelen metin görünmez karakter taşır: soft hyphen, zero-width space, null karakter, non-breaking space. Bunları karşılaştırma anında değil, sisteme girdikleri sınırda temizle (ISBN ve slug örneklerinde yaptığımız gibi) ve iç tarafta yalnız ordinal karşılaştırma kullan. Böylece aynı kitabın veritabanında iki kez durmasını da önlemiş olursun.
Üçüncüsü ölçüm. Bu yazıdaki byte sayıları allocation'ı gösteriyor, hızı göstermiyor. SearchValues ya da string.Create'e geçmeden önce o kodun gerçekten hot path'te (en sık çalışan kod yolunda) olduğunu BenchmarkDotNet ile ölçmek gerekir; Performance ve Load Testing serisinin BenchmarkDotNet yazısı bunun nasıl yapılacağını gösteriyor.
Sık Düşülen Tuzaklar
StringComparison vermeden IndexOf(string), StartsWith(string) ya da EndsWith(string) çağırmak. Bu overload'lar culture'a duyarlıdır; Türkçe sunucuda büyük-küçük harf duyarsız karşılaştırmada "I" ile "i" eşleşmez, ICU'nun sıfır ağırlıklı karakterleri yok sayması da yanlış pozitif üretir. Makine verisinde her zaman Ordinal ver.
Split sonrası boş elemanları unutmak. Sondaki virgül, çift virgül ve yalnız boşluktan oluşan parçalar diziye girer; parts[2] beklediğin değer olmaz. Kullanıcı girdisinde RemoveEmptyEntries | TrimEntries birlikte kullan.
Döngüde += ile string büyütmek. Bin elemanda 10 MB ayırdığını gördük; on bin elemanda bu karesel olarak büyür. string.Join ya da StringBuilder kullan.
Slug ya da anahtar üretirken ToLower() kullanmak. Culture'a göre farklı sonuç verir; makine tarafından kullanılan her string için ToLowerInvariant ya da ordinal karşılaştırma seç.
Rakam kontrolü için char.IsDigit kullanmak. Unicode'daki bütün rakam karakterlerine true döner; ASCII rakam bekliyorsan char.IsAsciiDigit (.NET 7) kullan.
Küçük Bir Deneme
★☆☆ strings-search örneğini DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 ortam değişkeniyle çalıştır ve hangi satırların değiştiğini not et. Sonra pasted.StartsWith("Middlemarch", StringComparison.InvariantCulture) satırını ekleyip iki modda tekrar dene.
★★☆ Slug.FromTitle'a "Straße" ve "Ærø" başlıklarını ver; çıkan slug'lara bak. Bu harfleri ASCII karşılıklarına (ss, ae, o) çeviren küçük bir eşleme tablosu ekle ve slug'ın son tirede kesilmesini sağlayan bir seçenek yaz.
★★★ IsbnNormalizer'a ISBN-10 desteği ekle: 10 haneli girdiyi kabul et (son hane X olabilir), kontrol hanesini doğrula ve 978 ön ekiyle ISBN-13'e çevir. Dönüşümde kontrol hanesinin yeniden hesaplanması gerektiğini unutma.
Bu yazıda string'leri arayıp böldük, birleştirdik ve temizledik. Fatura satırındaki {2,10:N2} gibi parçaların tam olarak ne yaptığını, aynı fiyatın neden tr-TR'de 1.249,50, en-US'de 1,249.50 yazıldığını ve kendi Isbn tipimizi format string'lerle nasıl yazdırılabilir yapacağımızı bir sonraki yazıda, standart ve custom format string'lerinde göreceğiz.