Mert Özen Her Satırda Daha İleri
Geliştirici Araçları Regular Expressions — #07

.NET'te Regex: GeneratedRegex, RegexOptions, Timeout, NonBacktracking ve Span API'leri

Mert Özen 15 Kas 2026 15 dk 5 görüntülenme
.NET'te Regex: GeneratedRegex, RegexOptions, Timeout, NonBacktracking ve Span API'leri

Sabit pattern'ler için [GeneratedRegex] kullan, runtime'da belli olan pattern'leri bir kez oluşturup sakla ve güvenilmeyen input'ta timeout ya da NonBacktracking ver; her çağrıda new Regex(..., Compiled) ölçümümüzde source generator'dan otuz üç bin kat yavaştı. BenchmarkDotNet ölçümü, allocation yapmayan EnumerateMatches, timeout ve NonBacktracking'in reddettiği yapılar, IgnoreCase'in kültürü ne zaman okuduğu ve Bookstore için ISBN doğrulama ile Türkçe slug üretimi gerçek çıktılarla.

Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.404, runtime 10.0.15) ve BenchmarkDotNet 0.15.8 ile derlenip denenmiştir. C# örnekleri .NET 10'un file-based app özelliğiyle dotnet run dosya.cs komutuyla çalışır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) katalog import'u her gece yüz binlerce ISBN'i (kitapların uluslararası standart numarası) doğruluyor. Emre doğrulama method'unu hızlandırmak istiyor ve üç sürüm yazıyor:

// A: her çağrıda Compiled option'lı yeni bir Regex
bool A(string isbn) => new Regex(Pattern, RegexOptions.Compiled).IsMatch(isbn);

// B: static method, pattern her çağrıda string olarak veriliyor
bool B(string isbn) => Regex.IsMatch(isbn, Pattern);

// C: source generator'ın ürettiği Regex
bool C(string isbn) => Isbn13.IsMatch(isbn);

"Compiled en hızlısıdır" diye düşünen A'yı seçer. Üç sürümü bir ISBN için hızlıdan yavaşa sırala. A ile C arasında kaç kat fark olur: 2, 20, 2000?

Interpreter, Compiled ve Source Generator

.NET'te bir Regex nesnesi oluştururken pattern parse ediliyor, bir ağaca çevriliyor, optimize ediliyor ve interpreter'ın çalıştıracağı bir komut listesine dönüşüyor. Varsayılan yol bu: interpreter. RegexOptions.Compiled aynı işin üzerine o pattern'e özel IL (.NET'in ara dili) üretiyor; eşleşme daha hızlı, ama oluşturma çok daha pahalı ve IL'in ilk kullanımda JIT (kodu çalışma anında makine koduna çeviren derleyici) ile derlenmesi de gerekiyor. .NET 7 ile gelen source generator ise aynı kodu derleme sırasında C# olarak üretiyor: runtime'da parse ve derleme yok, kod projenin bir parçası, debugger ile içine girilebiliyor.

Tahmindeki sorunun cevabını BenchmarkDotNet ile ölçtüm. Her benchmark aynı ISBN'i bir kez kontrol ediyor. İlk üçü her çağrıda pattern'den yola çıkıyor, sonraki dördü önceden oluşturulmuş tek bir instance'ı yeniden kullanıyor:

#:package BenchmarkDotNet@0.15.8
#:property Configuration=Release
#:property PublishAot=false

using System.Text.RegularExpressions;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<IsbnBenchmarks>(args: args);

[MemoryDiagnoser]
[InProcess]
public partial class IsbnBenchmarks
{
    private const string Pattern = @"^97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9]\z";
    private const string Input = "978-0-441-17271-9";

    private static readonly Regex Interpreted = new(Pattern);
    private static readonly Regex Compiled = new(Pattern, RegexOptions.Compiled);
    private static readonly Regex NonBacktracking = new(Pattern, RegexOptions.NonBacktracking);

    [GeneratedRegex(Pattern)]
    private static partial Regex Generated { get; }

    // her çağrıda oluşturma
    [Benchmark] public bool NewInterpreted() => new Regex(Pattern).IsMatch(Input);
    [Benchmark] public bool NewCompiled() => new Regex(Pattern, RegexOptions.Compiled).IsMatch(Input);
    [Benchmark] public bool StaticMethod() => Regex.IsMatch(Input, Pattern);

    // tek instance, yeniden kullanılıyor
    [Benchmark(Baseline = true)] public bool Interpreter() => Interpreted.IsMatch(Input);
    [Benchmark] public bool CompiledOption() => Compiled.IsMatch(Input);
    [Benchmark] public bool NonBacktrackingOption() => NonBacktracking.IsMatch(Input);
    [Benchmark] public bool SourceGenerated() => Generated.IsMatch(Input);
}

Komut dotnet run -c Release bench/regex-bench.cs. Çıktının başındaki ortam bilgisini atlayıp yalnızca özet tabloyu veriyorum. Bu tek bir makinede yapılmış bir ölçüm; mutlak süreler senin makinende farklı olur, oranlar ve sıralama ise genelde aynı kalır:

| Method                | Mean          | Error        | StdDev       | Ratio    | RatioSD | Gen0   | Gen1   | Allocated | Alloc Ratio |
|---------------------- |--------------:|-------------:|-------------:|---------:|--------:|-------:|-------:|----------:|------------:|
| NewInterpreted        |     938.71 ns |    17.612 ns |    17.298 ns |    18.34 |    0.33 | 0.4778 | 0.0038 |    4000 B |          NA |
| NewCompiled           | 358,805.47 ns | 6,026.581 ns | 9,203.226 ns | 7,010.00 |  178.09 | 1.4648 | 0.4883 |   14391 B |          NA |
| StaticMethod          |      52.59 ns |     0.290 ns |     0.257 ns |     1.03 |    0.01 |      - |      - |         - |          NA |
| Interpreter           |      51.19 ns |     0.154 ns |     0.144 ns |     1.00 |    0.00 |      - |      - |         - |          NA |
| CompiledOption        |      12.81 ns |     0.239 ns |     0.224 ns |     0.25 |    0.00 |      - |      - |         - |          NA |
| NonBacktrackingOption |      26.11 ns |     0.166 ns |     0.147 ns |     0.51 |    0.00 |      - |      - |         - |          NA |
| SourceGenerated       |      10.74 ns |     0.232 ns |     0.206 ns |     0.21 |    0.00 |      - |      - |         - |          NA |

Tahminin cevabı: C en hızlısı, B onun yaklaşık beş katı, A ise hiçbir seçenekte değil. NewCompiled bir ISBN'i yaklaşık 359 mikrosaniyede, SourceGenerated 10,7 nanosaniyede kontrol etti; aradaki fark otuz üç bin katın üzerinde. Sebebi tablonun ilk satırlarında: Compiled option'lı bir Regex oluşturmak pattern'i parse etmek, IL üretmek ve JIT'e derletmek demek; her çağrıda bu işi yeniden yapınca kazandığın birkaç nanosaniyeyi yüz binlerce kez geri ödüyorsun. Her çağrıda new Regex(Pattern) de iyi değil: 939 ns ve çağrı başına 4000 byte.

StaticMethod satırı ilk bakışta şaşırtıcı: her çağrıda pattern'i string olarak veriyor ama reuse edilen interpreter kadar hızlı. Static method'lar son kullanılan pattern'leri bir cache'te tutuyor, yani ikinci çağrıdan itibaren aynı Regex nesnesi kullanılıyor. Kalıcı instance'lar arasında Compiled ve source generator interpreter'ın yaklaşık dörtte birine iniyor, NonBacktracking yarısında kalıyor. Bu kısa ve iyi huylu bir input; NonBacktracking'in asıl değeri sekizinci yazıdaki kötü huylu input'larda görünecek.

GeneratedRegex: Partial Property, Partial Method ve SYSLIB1045

Source generator'ı kullanmak için partial bir class içinde [GeneratedRegex] attribute'u taşıyan bir partial member tanımlıyorsun; gövdesini generator yazıyor. .NET 7'de bu bir method olmak zorundaydı, .NET 9 ve C# 13'ten beri property de olabiliyor. Bookstore'un iki ihtiyacıyla görelim: ISBN doğrulama ve kitap adından URL'de kullanılacak slug üretmek.

using System.Text;
using System.Text.RegularExpressions;

foreach (string isbn in new[] { "978-0-441-17271-9", "978-0-441-17271-8", "978 0 441 17271 9" })
    Console.WriteLine($"{isbn,-20} {BookstoreText.IsIsbn13(isbn)}");

foreach (string title in new[] { "Çalıkuşu", "İnce Memed: Cilt 1", "Robinson Crusoe: Issız Bir Adada 28 Yıl", "  Dune -- 2. Baskı  " })
    Console.WriteLine($"{title,-42} {BookstoreText.Slugify(title)}");

static partial class BookstoreText
{
    // .NET 9 ve sonrası: partial property
    [GeneratedRegex(@"^97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9]\z")]
    private static partial Regex Isbn13Shape { get; }

    // .NET 7 ve sonrası: partial method
    [GeneratedRegex(@"\p{Mn}")]
    private static partial Regex CombiningMarks();

    [GeneratedRegex("[^a-z0-9]+")]
    private static partial Regex NonSlugCharacters { get; }

    public static bool IsIsbn13(string input)
    {
        // regex biçimi, kod uzunluğu ve kontrol hanesini denetler
        if (input.Length != 17 || !Isbn13Shape.IsMatch(input))
            return false;
        string digits = input.Replace("-", "");
        int sum = 0;
        for (int i = 0; i < 12; i++)
            sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
        return (10 - sum % 10) % 10 == digits[12] - '0';
    }

    public static string Slugify(string title)
    {
        // FormD ç'yi c ve birleşen çengele ayırır; noktasız ı'nın ayrışımı yok, önce onu çeviriyoruz
        string decomposed = title.Replace('ı', 'i').Normalize(NormalizationForm.FormD);
        string ascii = CombiningMarks().Replace(decomposed, "").ToLowerInvariant();
        return NonSlugCharacters.Replace(ascii, "-").Trim('-');
    }
}
$ dotnet run dotnet/bookstore-text.cs
978-0-441-17271-9    True
978-0-441-17271-8    False
978 0 441 17271 9    False
Çalıkuşu                                   calikusu
İnce Memed: Cilt 1                         ince-memed-cilt-1
Robinson Crusoe: Issız Bir Adada 28 Yıl    robinson-crusoe-issiz-bir-adada-28-yil
  Dune -- 2. Baskı                         dune-2-baski

ISBN tarafında iş bölümü birinci yazıdakiyle aynı: regex biçimi, kod kontrol hanesini denetliyor. İkinci ISBN'in biçimi doğru, ama son hanesi yanlış olduğu için False.

Slug fonksiyonunda regex iki yerde çalışıyor. Önce metin Unicode'un FormD normalizasyonuyla ayrıştırılıyor: ç, c ve ona eklenen çengel işaretine, İ, I ve üstündeki noktaya ayrılıyor. Dördüncü yazıda gördüğümüz Unicode kategorilerinden \p{Mn}, yani "boşluk kaplamayan işaret", bu eklenmiş işaretlerin hepsini tek pattern'le siliyor. Türkçedeki tek istisna noktasız ı: ayrıştırılacak bir parçası yok, o yüzden önce elle i'ye çevriliyor. Kalan her şeyi [^a-z0-9]+ tek bir tireye indiriyor; Trim('-') baştaki ve sondaki tireleri atıyor. Bu yöntem kültür bilgisine ihtiyaç duymuyor, yani dördüncü yazıdaki invariant mod sorununa takılmıyor.

Generator'ın ürettiği kodu görmek istersen Visual Studio'da property'nin üzerinde Go To Definition yeterli; Microsoft Learn'deki source generator sayfasına göre kod, pattern'i adım adım anlatan XML yorumlarıyla birlikte geliyor ve içine breakpoint koyabiliyorsun. Pattern'i derleme sırasında bilinen new Regex(...) ve Regex.IsMatch(..., "...") çağrılarını bulmak için de bir analyzer var: SYSLIB1045. IDE'de öneri olarak görünüyor ve fixer'ı çağrıyı [GeneratedRegex]'e çeviriyor. Build çıktısında görmek için klasöre bir .editorconfig koyup seviyesini warning'e çektim:

$ cat dotnet/syslib1045/.editorconfig
root = true

# SYSLIB1045'i (varsayılan olarak IDE önerisi) build uyarısı olarak göster
[*.cs]
dotnet_diagnostic.SYSLIB1045.severity = warning
$ cat dotnet/syslib1045/legacy.cs
using System.Text.RegularExpressions;

// runtime'da oluşturulan sabit pattern'ler: analyzer ikisi için de GeneratedRegex önerir
Regex orderNumber = new("BKS-[0-9]{4}-[0-9]{6}");
Console.WriteLine(orderNumber.IsMatch("BKS-2026-000123"));
Console.WriteLine(Regex.IsMatch("978-0-441-17271-9", "^97[89]-"));
$ dotnet build --no-incremental dotnet/syslib1045/legacy.cs 2>&1 | grep -o 'legacy.cs([0-9,]*): warning SYSLIB1045: [^(]*time\.'
legacy.cs(4,21): warning SYSLIB1045: Use 'GeneratedRegexAttribute' to generate the regular expression implementation at compile-time.
legacy.cs(6,19): warning SYSLIB1045: Use 'GeneratedRegexAttribute' to generate the regular expression implementation at compile-time.

--no-incremental bilerek orada: dosya değişmemişse build derleme adımını atlıyor ve uyarılar ikinci build'de hiç görünmüyor.

Generator'ın tek yapamadığı şey NonBacktracking için kod üretmek; o option verildiğinde, dokümana göre, cache'lenmiş sıradan bir Regex instance'ına dönüyor. RegexOptions.Compiled'ı ise yok sayıyor, çünkü ürettiği kod zaten derlenmiş.

Match, Matches, Replace, Split ve Allocation Yapmayan API'ler

Regex class'ının API'leri iki gruba ayrılıyor: sonucu Match nesneleri ve string'ler olarak veren klasik method'lar ve .NET 7'den sonra gelen, ReadOnlySpan<char> ile çalışıp allocation yapmayan method'lar:

using System.Text.RegularExpressions;

string log = File.ReadAllText("data/app.log");

// Count, Match, Matches
Console.WriteLine($"Count: {Patterns.OrderNumber.Count(log)}");
Console.WriteLine($"Match: {Patterns.OrderNumber.Match(log).Value}");
Console.WriteLine($"Matches: {string.Join(", ", Patterns.OrderNumber.Matches(log).Select(m => m.Value[^3..]))}");

// MatchEvaluator ile Replace: replacement her eşleşme için hesaplanır
string firstLine = log[..log.IndexOf('\n')];
Console.WriteLine(Patterns.OrderNumber.Replace(firstLine, m => m.Value[..9] + "****" + m.Value[^2..]));

// Split boş parçaları korur; EnumerateSplits (.NET 9) string yerine Range döndürür
string titles = "Dune; Emma ,Middlemarch;;The Hobbit";
Console.WriteLine(string.Join(" | ", Patterns.Separator.Split(titles)));
foreach (Range range in Patterns.Separator.EnumerateSplits(titles))
    Console.Write($"[{titles[range]}]");
Console.WriteLine();

// ReadOnlySpan<char> üzerinde IsMatch: satırın bir dilimi, Substring yok
ReadOnlySpan<char> lastFifteen = firstLine.AsSpan()[^15..];
Console.WriteLine($"IsMatch(span): {Patterns.OrderNumber.IsMatch(lastFifteen)}");

// allocation: Matches Match nesneleri ve string'ler oluşturur, EnumerateMatches oluşturmaz
Measure("Matches", () => { int n = 0; foreach (Match m in Patterns.OrderNumber.Matches(log)) n += m.Value.Length; return n; });
Measure("EnumerateMatches", () => { int n = 0; foreach (ValueMatch m in Patterns.OrderNumber.EnumerateMatches(log)) n += m.Length; return n; });

static void Measure(string name, Func<int> work)
{
    work(); // warm-up: JIT'in yaptığı allocation sayılmasın
    long before = GC.GetAllocatedBytesForCurrentThread();
    int chars = work();
    long after = GC.GetAllocatedBytesForCurrentThread();
    Console.WriteLine($"{name,-17} {chars} chars, {after - before} bytes allocated");
}

static partial class Patterns
{
    [GeneratedRegex("BKS-[0-9]{4}-[0-9]{6}")]
    public static partial Regex OrderNumber { get; }

    [GeneratedRegex(@"\s*[;,]\s*")]
    public static partial Regex Separator { get; }
}
$ dotnet run dotnet/regex-apis.cs
Count: 4
Match: BKS-2026-000123
Matches: 123, 124, 125, 126
2026-11-15T10:42:07Z INF Bookstore.Payments Payment 4711 authorized for BKS-2026-****23
Dune | Emma | Middlemarch |  | The Hobbit
[Dune][Emma][Middlemarch][][The Hobbit]
IsMatch(span): True
Matches           60 chars, 1232 bytes allocated
EnumerateMatches  60 chars, 0 bytes allocated

Count (.NET 7) yalnızca sayıyor ve Match nesnesi oluşturmuyor; "kaç tane var" sorusu için Matches(...).Count yazmaya gerek yok. Replace'in MatchEvaluator alan overload'u, replacement'ı her eşleşme için kodla hesaplamanı sağlıyor; burada sipariş numarasının ortasını maskeledik. Altıncı yazının ★★★ alıştırmasındaki log refactor'u da bu overload ile yazılır. Split ardışık iki ayırıcı arasında boş bir parça bırakıyor; ;; yüzünden çıktıda iki | arası boş.

Son iki satır farkı sayıyla gösteriyor. Matches dört eşleşme için 1232 byte ayırdı: bir MatchCollection, dört Match nesnesi ve Value ile üretilen dört string. EnumerateMatches ise bir ref struct enumerator ve yalnızca Index ve Length taşıyan ValueMatch döndürdüğü için sıfır byte ayırdı. Bedeli şu: ValueMatch'te grup bilgisi yok. Grupları okumanın gerekmediği sayma, filtreleme, konum bulma işlerinde ve saniyede binlerce satır işlenen yerlerde span API'lerini kullan; grup gerekiyorsa Match'e dön.

Timeout ve NonBacktracking

Sekizinci yazıda ölçeceğimiz catastrophic backtracking'in .NET'teki iki savunması timeout ve NonBacktracking. İkisini aynı kötü pattern'le, ^(a+)+$ ve neredeyse eşleşen bir input ile görelim:

using System.Diagnostics;
using System.Text.RegularExpressions;

// process genelinde varsayılan timeout; bir kez okunur, ilk Regex kullanımından önce ayarla
AppContext.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", TimeSpan.FromSeconds(2));

// iç içe quantifier ve neredeyse eşleşen bir input
string input = new string('a', 30) + "!";

// 1. timeout: backtracking engine'i 100 ms sonra vazgeçer
Regex withTimeout = new(@"^(a+)+$", RegexOptions.None, TimeSpan.FromMilliseconds(100));
try
{
    withTimeout.IsMatch(input);
}
catch (RegexMatchTimeoutException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.MatchTimeout.TotalMilliseconds} ms");
}

// 2. NonBacktracking: aynı pattern, aynı input, lineer zaman
Stopwatch sw = Stopwatch.StartNew();
bool matched = Regex.IsMatch(input, @"^(a+)+$", RegexOptions.NonBacktracking);
Console.WriteLine($"NonBacktracking: {matched} in under 100 ms: {sw.ElapsedMilliseconds < 100}");

// 3. backtracking gerektiren yapılar Regex oluşturulurken reddedilir
foreach (string pattern in new[] { @"Payment (?=\d)", @"\b(\w+) \1\b", @"(?>\d+)0", @"(?<=BKS-)\d+" })
{
    try
    {
        _ = new Regex(pattern, RegexOptions.NonBacktracking);
        Console.WriteLine($"{pattern,-16} ok");
    }
    catch (NotSupportedException ex)
    {
        Console.WriteLine($"{pattern,-16} {ex.Message}");
    }
}

// 4. timeout'suz oluşturulan her Regex process genelindeki varsayılanı alır
Console.WriteLine($"default timeout: {new Regex("BKS").MatchTimeout}");
$ dotnet run dotnet/timeout-nonbacktracking.cs
RegexMatchTimeoutException: 100 ms
NonBacktracking: False in under 100 ms: True
Payment (?=\d)   RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'positive lookahead (?= pattern) or positive lookbehind (?<= pattern)'.
\b(\w+) \1\b     RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'backreference (\ number)'.
(?>\d+)0         RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'atomic subexpressions (?> pattern)'.
(?<=BKS-)\d+     RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'positive lookahead (?= pattern) or positive lookbehind (?<= pattern)'.
default timeout: 00:00:02

Timeout'un varsayılan değeri sonsuz. Bir değer verdiğinde engine, süre dolunca RegexMatchTimeoutException fırlatıyor; .NET dokümanına göre süre biraz aşılabilir ama backtracking'e verilen süreden fazla zaman harcanmaz. Exception'ı yakalayıp "eşleşme yok" saymak, log'a yazıp isteği reddetmek senin kararın. Timeout'u üç yerden verebilirsin: Regex constructor'ı, static method'ların matchTimeout parametresi ve [GeneratedRegex]'in matchTimeoutMilliseconds parametresi. Hiçbiri verilmemişse process genelindeki REGEX_DEFAULT_MATCH_TIMEOUT ayarı geçerli. Bu ayarı denerken bir şey fark ettim: SetData çağrısını programın sonuna, birkaç Regex kullanıldıktan sonraya koyduğumda yeni Regex'in timeout'u hâlâ sonsuzdu; değer bir kez okunuyor. O yüzden ayar, programın ilk satırlarında.

NonBacktracking ise sorunu kökünden çözüyor: beşinci yazıdaki RE2 (Google'ın backtracking yapmayan regex library'si) ailesi gibi input'u bir kez okuyor ve aynı input'ta timeout'a hiç yaklaşmadan False döndü. Karşılığında desteklemediği yapılar var ve bunlar eşleşme sırasında değil, Regex oluşturulurken NotSupportedException ile reddediliyor. Microsoft Learn'deki RegexOptions sayfasının listesi: atomic group, backreference, balancing group, conditional, lookaround ve \G. Bir fark daha var: döngü içindeki bir grup için yalnızca son yakalanan değeri veriyor, backtracking engine'i gibi bütün yakalamaları değil.

Bir uyarı da .NET dokümanından. Engine pattern'in güvenilir olduğunu varsayıyor; timeout ve NonBacktracking pahalı input'a karşı koruma, kötü niyetli pattern'e karşı değil. Kullanıcının yazdığı metni pattern olarak çalıştırma: ya Regex.Escape ile literal'e çevir ya da beşinci yazıdaki gibi basit bir wildcard syntax'ı sunup regex'e kendin çevir.

IgnoreCase ve Kültür: new Regex ile GeneratedRegex Farklı Davranıyor

Dördüncü yazıda IgnoreCase'in sonucunun kültüre bağlı olduğunu gördük. Kültürün tam olarak ne zaman okunduğu ve source generator'da ne olduğu da önemli:

using System.Globalization;
using System.Text.RegularExpressions;

string title = "ROBINSON CRUSOE: ISSIZ BİR ADADA 28 YIL";

// new Regex: kültür eşleşme anında değil, Regex oluşturulurken okunur
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("tr-TR");
Regex builtUnderTurkish = new("ıssız", RegexOptions.IgnoreCase);
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Regex builtUnderInvariant = new("ıssız", RegexOptions.IgnoreCase);
Console.WriteLine($"new Regex, built under tr-TR:     {builtUnderTurkish.IsMatch(title)}");
Console.WriteLine($"new Regex, built under invariant: {builtUnderInvariant.IsMatch(title)}");

// GeneratedRegex: kültür derleme sırasında cultureName ile sabitlenir
Console.WriteLine($"generated, cultureName tr-TR:     {Titles.IssizTurkish.IsMatch(title)}");
Console.WriteLine($"generated, no cultureName:        {Titles.InceDefault.IsMatch("İNCE MEMED")}");

// aynı pattern ve option'lar ikinci kez, bu sefer tr-TR ile
Console.WriteLine($"generated, same pattern, tr-TR:   {Titles.InceTurkish.IsMatch("İNCE MEMED")}");
Console.WriteLine($"same instance: {ReferenceEquals(Titles.InceDefault, Titles.InceTurkish)}");

static partial class Titles
{
    [GeneratedRegex("ıssız", RegexOptions.IgnoreCase, "tr-TR")]
    public static partial Regex IssizTurkish { get; }

    [GeneratedRegex("ince", RegexOptions.IgnoreCase)]
    public static partial Regex InceDefault { get; }

    [GeneratedRegex("ince", RegexOptions.IgnoreCase, "tr-TR")]
    public static partial Regex InceTurkish { get; }
}
$ dotnet run dotnet/ignorecase-culture.cs
new Regex, built under tr-TR:     True
new Regex, built under invariant: False
generated, cultureName tr-TR:     True
generated, no cultureName:        False
generated, same pattern, tr-TR:   False
same instance: True

İlk iki satır: Regex Türkçe kültürde oluşturulmuşsa, sonradan kültür değişse de Türkçe kurallarla eşleşmeye devam ediyor. Kültür eşleşme anında değil, oluşturma anında okunuyor. Uygulama başlarken static readonly bir alanda oluşturulan Regex, o an hangi thread'in kültürü geçerliyse onu taşıyor.

Source generator'da kültür derleme sırasında belli olmak zorunda, çünkü büyük/küçük harf eşleşmeleri üretilen koda gömülüyor. cultureName olarak tr-TR verince ıssız ISSIZ'i buldu. cultureName vermediğinde runtime'daki kültüre bakılmıyor; ince İNCE'yi bulmadı.

Son iki satır denerken karşılaştığım bir davranış: aynı pattern'i ve aynı option'ları iki kez, bir kez kültürsüz bir kez tr-TR ile tanımladığımda generator ikisi için tek bir implementation üretti ve ikinci tanımın kültürünü yok saydı; ReferenceEquals True. Hangi tanımın kazandığı kaynak koddaki sıraya bağlı. Bunu SDK 10.0.404 ve 11.0.100'de gözlemledim; dokümanda bu durum anlatılmıyor. Pratik sonucu basit: bir pattern'in kültürlü ve kültürsüz iki sürümüne ihtiyacın varsa ikisini aynı projede source generator'a verme, birini new Regex ile oluştur ve kültürünü açıkça ver.

Üretimde Dikkat

Pattern derleme sırasında biliniyorsa [GeneratedRegex] kullan; SYSLIB1045'in önerilerini takip etmek yeterli. Pattern runtime'da belli oluyorsa (konfigürasyondan gelen bir log filtresi gibi) Regex'i bir kez oluştur ve sakla. Static method'ların cache'i dokümana göre son kullanılan 15 pattern'i tutuyor; daha fazla farklı pattern'i static method'larla döndürürsen her biri yeniden parse ediliyor ve benchmark'taki NewInterpreted satırına düşüyorsun. Gerekirse Regex.CacheSize'ı büyütebilirsin, ama kendi ConcurrentDictionary'inde tutmak daha öngörülebilir.

Her çağrıda new Regex(..., RegexOptions.Compiled) yazma. Compiled'ın anlamı "bir kez pahalı oluştur, çok kez hızlı kullan"; her çağrıda oluşturulunca en pahalı seçenek oluyor. Native AOT (uygulamanın önceden makine koduna derlendiği yayın biçimi) gibi dinamik kod üretilemeyen ortamlarda Compiled etkisiz kalıyor, source generator ise çalışıyor.

Güvenilmeyen input'u işleyen her Regex'e timeout ver ya da NonBacktracking kullan. RegexMatchTimeoutException'ı log'la: hangi pattern'in, ne uzunlukta bir input'ta zaman aşımına uğradığını bilmek, sekizinci yazıdaki sorunu production'da fark etmenin en kısa yolu.

Sık Düşülen Tuzaklar

Kullanıcıdan gelen metni Regex.Escape'siz pattern'e koymak

C++ araması Nested quantifier exception'ı fırlatır, Dune (1965) araması grup açar ve parantezsiz metni bulur. Arama terimi her zaman Regex.Escape'ten geçmeli; kullanıcıya regex yazdırmak istiyorsan pattern'e güvenme.

IgnoreCase'in sonucunu sabit sanmak

new Regex oluşturulduğu andaki kültürü taşır; [GeneratedRegex] cultureName verilmezse runtime kültürüne hiç bakmaz. Türkçe metinde kültürü açıkça ver; sistem tanımlayıcılarında CultureInvariant kullan.

NonBacktracking'i her pattern'e eklemek

Lookaround, backreference ya da atomic group içeren pattern oluşturulurken exception fırlatır. Option'ı eklediğin her pattern'i en az bir kez oluşturan bir test yaz.

EnumerateMatches ile grup okumaya çalışmak

ValueMatch yalnızca konum ve uzunluk taşır. Grup gerekiyorsa Match kullan ya da eşleşen dilimi ikinci bir Regex ile işle.

REGEX_DEFAULT_MATCH_TIMEOUT'u geç ayarlamak

Değer bir kez okunuyor. İlk Regex kullanımından sonra yapılan SetData çağrısı etkisiz kalıyor; ayarı uygulamanın başlangıcına koy.

Küçük Bir Deneme

★☆☆ regex-apis.cs'deki Separator pattern'ini, ;; gibi ardışık ayırıcılar boş parça üretmeyecek biçimde değiştir. Split ve EnumerateSplits'in çıktısını karşılaştır.

★★☆ Benchmark'a data/access.log'un tamamında Count çağıran dört method ekle: interpreter, Compiled, source generator ve NonBacktracking. Uzun bir input'ta sıralama ISBN'deki gibi mi kalıyor?

★★★ Bookstore'un arama endpoint'i için, kullanıcının yazdığı * ve ? wildcard'larını destekleyen bir arama fonksiyonu yaz: metni Regex.Escape ile kaçışla, sonra yalnızca kaçışlanmış \* ve \?'yi .* ve .'ya çevir, 100 ms timeout ver ve timeout'u log'la.

Böylece .NET tarafını da tamamladık. Serinin son yazısında regex'in bedeline bakacağız: catastrophic backtracking'i ölçerek göreceğiz, Cloudflare'in 2019 kesintisinin resmî raporunu okuyacağız, savunmaları karşılaştıracağız ve regex'i okunur ve test edilmiş tutmanın yollarını konuşacağız.