Mert Özen Her Satırda Daha İleri
Backend C# ile Günlük .NET API'leri — #04

Sayılarla Günlük İşler: Math, Yuvarlama Modları, Random.Shared, Guid v4/v7 ve BigInteger

Mert Özen 15 Kas 2026 16 dk 9 görüntülenme
Sayılarla Günlük İşler: Math, Yuvarlama Modları, Random.Shared, Guid v4/v7 ve BigInteger

Math.Round varsayılan olarak çifte yuvarlar (2,5 → 2), decimal format'ları ise sıfırdan uzağa; para için modu açıkça verip önce yuvarlamak, sonra format'lamak gerekir. Bookstore'un kampanya indirimi üzerinden MidpointRounding modlarını, double ile para hatasını ve .NET 11'deki decimal dönüşüm değişikliğini, Random.Shared ile thread güvenliğini, Guid v7'nin sıralama davranışını ve BigInteger'ı gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; Random.NextInteger ve decimal dönüşümü örnekleri .NET 11 SDK ile çalıştırılmıştır.

Önce Tahmin Et

Üç kısa satır. Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) fiyat servisinde benzerlerini her gün yazıyoruz:

Console.WriteLine(Math.Round(2.5));
Console.WriteLine(Math.Round(3.5));
Console.WriteLine(0.1 + 0.2 == 0.3);
2
4
False

Okulda öğrendiğimiz kural 2,5'i 3'e yuvarlar. Math.Round ise varsayılan olarak "çifte yuvarlama" yapar: tam ortadaki değeri en yakın çift sayıya götürür, bu yüzden 2,5 2'ye, 3,5 4'e gider. Üçüncü satır double'ın 0,1'i tam tutamamasından geliyor. İkisi de para hesabında kuruş farkına dönüşür.

Serinin ilk üç yazısı metinle ilgiliydi. Bu yazıda sayılara geçiyoruz: Math.Round'un yuvarlama modları ve format ile yuvarlamanın farkı, decimal yardımcıları, kampanya indirimini kuruş hassasiyetinde hesaplamak, INumber<T> ile generic yardımcılar, Random ve Random.Shared, sipariş için zamana göre sıralı Guid v7, taşma ve BigInteger. Sayı tiplerinin temeli ve decimal/double farkı C# Temelleri serisinin "C# Sayılar ve Aritmetik" yazısında; burada her gün kullanılan API'lerin ayrıntısına iniyoruz.

dotnet run --project samples/Bookstore.Bcl.Samples -- numbers-rounding --culture en-US

Math.Round ve MidpointRounding

MidpointRounding enum'u yalnız tam ortadaki değerin nereye gideceğini belirler. .NET Core 3.0'dan beri beş mod var. Aynı değerleri her modla yuvarladım:

decimal[] values = [2.5m, 3.5m, -2.5m, 2.4m, 2.6m];
MidpointRounding[] modes =
[
    MidpointRounding.ToEven, MidpointRounding.AwayFromZero, MidpointRounding.ToZero,
    MidpointRounding.ToNegativeInfinity, MidpointRounding.ToPositiveInfinity,
];

Console.WriteLine($"{"value",-8}" + string.Concat(modes.Select(m => $"{m,20}")));
foreach (decimal value in values)
    Console.WriteLine($"{value.ToString(Inv),-8}" + string.Concat(modes.Select(m => $"{Math.Round(value, m).ToString(Inv),20}")));

Console.WriteLine($"Math.Round(2.345m, 2)                   = {Math.Round(2.345m, 2).ToString(Inv)}");
Console.WriteLine($"Math.Round(2.345m, 2, AwayFromZero)     = {Math.Round(2.345m, 2, MidpointRounding.AwayFromZero).ToString(Inv)}");
Console.WriteLine($"Math.Round(2.345, 2, AwayFromZero) (double) = {Math.Round(2.345, 2, MidpointRounding.AwayFromZero).ToString(Inv)}");
Console.WriteLine($"2.345 as G17 = {2.345.ToString("G17", Inv)}");
value                 ToEven        AwayFromZero              ToZero  ToNegativeInfinity  ToPositiveInfinity
2.5                        2                   3                   2                   2                   3
3.5                        4                   4                   3                   3                   4
-2.5                      -2                  -3                  -2                  -3                  -2
2.4                        2                   2                   2                   2                   3
2.6                        3                   3                   2                   2                   3
Math.Round(2.345m, 2)                   = 2.34
Math.Round(2.345m, 2, AwayFromZero)     = 2.35
Math.Round(2.345, 2, AwayFromZero) (double) = 2.35
2.345 as G17 = 2.3450000000000002

ToEven (banker's rounding; bankacılıkta kullanılan, ortadaki değeri çifte yuvarlayan yöntem) çok sayıda yuvarlamanın toplamında sistematik bir sapma oluşturmadığı için varsayılan. AwayFromZero okulda öğrendiğimiz yuvarlama: pozitifte yukarı, negatifte aşağı. Tablodaki 2,4 ve 2,6 satırlarına dikkat: ToZero, ToNegativeInfinity ve ToPositiveInfinity adlarına rağmen yalnız orta noktayı değil her değeri o yöne götürüyor; aslında bunlar Truncate, Floor ve Ceiling'in belirli basamağa uygulanmış hâlleri. Dokümantasyon da bu üç modu "directed rounding" olarak ayrı tutuyor.

Son iki satır double ile yuvarlamanın bir başka tuzağını gösteriyor. 2,345 double'da aslında 2,3450000000000002; yani "tam orta" değil, ortanın biraz üstü. Burada sonuç tesadüfen doğru çıktı, ama ikili tabanda tam tutulamayan her ondalık sayıda double ile orta nokta kuralı güvenilmez. Para ve oran hesabında decimal kullanmanın ana sebebi bu.

Format ile Yuvarlama Aynı Şey Değil

Önceki yazıda format string'lerinin orta noktayı sıfırdan uzağa yuvarladığını görmüştük. Aynı değeri format'la ve Math.Round ile yuvarlayınca:

Console.WriteLine($"{"",-14}{"F0",6}{"\"0\"",6}{"Math.Round",12}");
Row("decimal 2.5", 2.5m.ToString("F0", Inv), 2.5m.ToString("0", Inv), Math.Round(2.5m).ToString(Inv));
Row("double 2.5", 2.5.ToString("F0", Inv), 2.5.ToString("0", Inv), Math.Round(2.5).ToString(Inv));
Row("decimal 0.125", 0.125m.ToString("F2", Inv), 0.125m.ToString("0.00", Inv), Math.Round(0.125m, 2).ToString(Inv));
Row("double 0.125", 0.125.ToString("F2", Inv), 0.125.ToString("0.00", Inv), Math.Round(0.125, 2).ToString(Inv));
                  F0   "0"  Math.Round
decimal 2.5        3     3           2
double 2.5         2     3           2
decimal 0.125   0.13  0.13        0.12
double 0.125    0.12  0.13        0.12

Üç farklı kural var: decimal'ın standart formatları ve bütün custom formatlar sıfırdan uzağa, double'ın standart formatları (.NET Core 3.0'dan beri) çifte, Math.Round varsayılan olarak çifte yuvarlıyor. Faturada satır tutarını F2 ile gösterip toplamı Math.Round ile hesaplarsan, ekrandaki satırların toplamı alttaki toplamla tutmayabilir. Çözüm: değeri önce açık bir modla yuvarla, sonra format'la. Format'ın işi yuvarlamak değil göstermek olmalı.

decimal, double ve Para

decimal ondalık tabanda çalışır, 28-29 anlamlı basamak tutar ve 0,1'i tam temsil eder. double ikili tabanda çalışır, daha hızlıdır ve bilimsel hesaplar için uygundur, ama 0,1'i tam tutamaz:

double total = 0;
decimal exact = 0;
for (int i = 0; i < 10; i++)
{
    total += 0.1;
    exact += 0.1m;
}
Console.WriteLine($"double  sum of ten 0.1 = {total.ToString("R", Inv)}, == 1.0: {total == 1.0}");
Console.WriteLine($"decimal sum of ten 0.1 = {exact.ToString(Inv)}, == 1.0m: {exact == 1.0m}");

double price = 349.90;
Console.WriteLine($"349.90 * 3 as double  = {(price * 3).ToString("R", Inv)}");
Console.WriteLine($"349.90m * 3 as decimal = {(349.90m * 3).ToString(Inv)}");
Console.WriteLine($"(decimal)0.1 (double literal) = {((decimal)0.1).ToString(Inv)}");
Console.WriteLine($"(decimal)349.90 (double literal) = {((decimal)349.90).ToString(Inv)}");
double  sum of ten 0.1 = 0.9999999999999999, == 1.0: False
decimal sum of ten 0.1 = 1.0, == 1.0m: True
349.90 * 3 as double  = 1049.6999999999998
349.90m * 3 as decimal = 1049.70
(decimal)0.1 (double literal) = 0.1
(decimal)349.90 (double literal) = 349.9

Üç Dune kitabının toplamı double'da 1049,6999999999998. Ekranda F2 ile 1049,70 görünür ama veritabanına, ödeme sağlayıcısına ya da bir karşılaştırmaya bu değer gider. decimal ise 1049.70 veriyor; sondaki sıfır da korunuyor, çünkü decimal ölçeğini (scale) taşır.

Son iki satır .NET 10'da double'dan decimal'a dönüşümün değeri yaklaşık 15 anlamlı basamağa yuvarladığını gösteriyor; 0.1 literal'i "temiz" bir 0.1m'ye dönüşüyor. .NET 11'de bu davranış değişti. Aynı satırları Bookstore.Bcl.Net11 projesinde çalıştırınca:

Console.WriteLine($"(decimal)0.1    = {((decimal)0.1).ToString(invariant)}");
Console.WriteLine($"(decimal)349.90 = {((decimal)349.90).ToString(invariant)}");
Console.WriteLine($"349.90m         = {349.90m.ToString(invariant)}");
(decimal)0.1    = 0.1000000000000000055511151231
(decimal)349.90 = 349.89999999999997726263245568
349.90m         = 349.90

.NET 11 dönüşümü kaynak double'ın tam ikili değerinden, tek bir yuvarlamayla yapıyor; Microsoft bunun için bir uyumluluk anahtarı sunmuyor. Fiyatı double olarak okuyup decimal'a çeviren eski kod .NET 11'e geçince fiyatı 349.90 değil 349.89999999999997726263245568 olarak saklamaya başlar ve bu değer yuvarlamalarda kuruş farkı üretir. Kural değişmedi, sadece ihlalin bedeli arttı: para değerini baştan decimal olarak oku (349.90m, decimal.Parse, veritabanında decimal(18,2)).

decimal'ın yardımcıları da bilmeye değer:

decimal price = -12.75m;
Console.WriteLine($"Floor    {decimal.Floor(price).ToString(Inv),8}   Ceiling {decimal.Ceiling(price).ToString(Inv),8}");
Console.WriteLine($"Truncate {decimal.Truncate(price).ToString(Inv),8}   Round   {decimal.Round(price, 1).ToString(Inv),8}");
Console.WriteLine($"Clamp(150, 0, 100) = {Math.Clamp(150, 0, 100)}, Clamp(-0.05m, 0m, 0.5m) = {Math.Clamp(-0.05m, 0m, 0.5m).ToString(Inv)}");
Console.WriteLine($"Scale of 1.50m = {(1.50m).Scale}, 1.50m == 1.5m: {1.50m == 1.5m}, ToString: {1.50m.ToString(Inv)} vs {1.5m.ToString(Inv)}");
Floor         -13   Ceiling      -12
Truncate      -12   Round      -12.8
Clamp(150, 0, 100) = 100, Clamp(-0.05m, 0m, 0.5m) = 0
Scale of 1.50m = 2, 1.50m == 1.5m: True, ToString: 1.50 vs 1.5

Negatif sayıda Floor ve Truncate farklı sonuç verir: Floor eksi sonsuza, Truncate sıfıra gider. İade tutarı gibi negatif değerlerle çalışırken bu fark önem kazanır. decimal.Round(-12.75m, 1) çifte yuvarlamayla -12,8 oldu. Math.Clamp bir indirim oranını 0 ile yüzde 50 arasına sıkıştırmak gibi sınır kontrolleri için tek satırlık çözüm. 1.50m ile 1.5m eşittir ama farklı yazılır; JSON'a giden fiyatta bu fark görünür, bu yüzden API sınırında ölçeği decimal.Round(x, 2) ile sabitlemek iyi bir alışkanlık.

INumber<T> ile Generic Yardımcılar

.NET 7 ile gelen generic math, aynı hesap fonksiyonunu bütün sayı tipleri için bir kez yazmanı sağlar. Yüzde hesaplayan bir yardımcı:

// int, long, decimal, double... INumber<T> uygulayan her tip için çalışır.
public static T PercentOf<T>(T value, T percent) where T : INumber<T> =>
    value * percent / T.CreateChecked(100);

Console.WriteLine($"int     : {PercentOf(250, 15)}");
Console.WriteLine($"decimal : {PercentOf(349.90m, 15m).ToString(Inv)}");
Console.WriteLine($"double  : {PercentOf(349.90, 15.0).ToString(Inv)}");
Console.WriteLine($"byte    : {PercentOf<byte>(200, 50)}");
int     : 37
decimal : 52.485
double  : 52.485
byte    : 0

int'te tamsayı bölmesi 37,5'i 37'ye kesti. byte satırı daha ilginç: 200 × 50 = 10.000 byte'a sığmadığı için ara sonuç sessizce taştı (10.000 mod 256 = 16) ve 16 / 100 = 0 çıktı. Generic kod, tipin sınırlarını unutmanı kolaylaştırır; taşmayı yakalamak istiyorsan çarpımı checked blokta yap ya da daha geniş bir tipe çevir. Generic math'in ayrıntısı C# Koleksiyonlar ve Generics serisinin "C# 11–12 ve .NET 7–8: Generic Math, Frozen ve Collection Expressions" yazısında.

Bookstore: Kampanya İndirimini Kuruş Hassasiyetinde Hesaplamak

Sepette üç kitap var ve kampanya yüzde 15 indirim veriyor. İndirimi her satırda ayrı mı, ara toplamda bir kez mi yuvarlayacağız?

(string Title, decimal Price)[] cart = [("Dune", 349.90m), ("Emma", 129.90m), ("Middlemarch", 249.90m)];
const decimal rate = 0.15m;

decimal perLine = 0;
foreach (var (title, price) in cart)
{
    decimal raw = price * rate;
    decimal rounded = Math.Round(raw, 2, MidpointRounding.AwayFromZero);
    perLine += rounded;
    Console.WriteLine($"{title,-12} {price.ToString("F2", Inv),8} x 15% = {raw.ToString(Inv),-10} -> {rounded.ToString("F2", Inv)}");
}

decimal subtotal = cart.Sum(line => line.Price);
decimal onTotal = Math.Round(subtotal * rate, 2, MidpointRounding.AwayFromZero);
Console.WriteLine($"Sum of rounded lines   = {perLine.ToString("F2", Inv)}");
Console.WriteLine($"Rounded on subtotal    = {onTotal.ToString("F2", Inv)} (subtotal {subtotal.ToString("F2", Inv)})");
Console.WriteLine($"ToEven on 52.485       = {Math.Round(349.90m * rate, 2).ToString(Inv)}");
Dune           349.90 x 15% = 52.4850    -> 52.49
Emma           129.90 x 15% = 19.4850    -> 19.49
Middlemarch    249.90 x 15% = 37.4850    -> 37.49
Sum of rounded lines   = 109.47
Rounded on subtotal    = 109.46 (subtotal 729.70)
ToEven on 52.485       = 52.48

Fiyatların hepsi ,90 ile bittiği için her satırın indirimi tam yarım kuruşa denk geliyor; satır bazında yuvarlayınca üç kez yukarı gidiyoruz ve toplam indirim 109,47 TL oluyor. Ara toplamda bir kez yuvarlayınca 109,46 TL. Bir kuruşluk fark küçük görünür, ama fatura satırlarının toplamı fatura toplamını tutmayınca muhasebe entegrasyonu siparişi reddeder. Son satır aynı satırı varsayılan ToEven ile yuvarlıyor: 52,48. Yani mod seçimi de bir iş kuralı.

Doğru yaklaşım tek bir kural seçip onu her yerde uygulamak. Hangi kuralın geçerli olduğu muhasebe ve e-fatura entegrasyonunun beklentisine bağlı; Bookstore'da yarım kuruşu yukarı yuvarlayan AwayFromZero'yu seçtik. İndirimi ara toplamda hesaplayıp satırlara dağıtıyorsan, yuvarlama farkını son satıra ya da en büyük kalanı olan satıra yüklersin; böylece satırların toplamı her zaman ara toplamın indirimine eşit olur. Bu dağıtma algoritması ve Money value object'i Çözüm Desenleri serisinin "Para ve Yuvarlama" yazısında ayrıntılı.

Random, Random.Shared ve RandomNumberGenerator

.NET'te üç rastgele sayı kaynağı var ve her birinin yeri ayrı:

var seeded1 = new Random(2026);
var seeded2 = new Random(2026);
Console.WriteLine($"new Random(2026) twice : {seeded1.Next(1000)} {seeded1.Next(1000)} {seeded1.Next(1000)} | {seeded2.Next(1000)} {seeded2.Next(1000)} {seeded2.Next(1000)}");

var unseeded = Enumerable.Range(0, 3).Select(_ => new Random().Next(1000)).ToArray();
Console.WriteLine($"new Random() x3 in a loop: {string.Join(" ", unseeded)}");

string[] featured = ["Dune", "Emma", "Middlemarch", "The Hobbit", "Ulysses", "Ivanhoe"];
var picker = new Random(7);
Console.WriteLine($"GetItems(3), seed 7    : {string.Join(", ", picker.GetItems(featured, 3))}");
string[] shuffled = [.. featured];
picker.Shuffle(shuffled);
Console.WriteLine($"Shuffle, seed 7        : {string.Join(", ", shuffled)}");

string coupon = RandomNumberGenerator.GetString("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", 10);
Console.WriteLine($"Coupon code length {coupon.Length}, only allowed chars: {coupon.All(c => "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".Contains(c))}");
new Random(2026) twice : 159 283 917 | 159 283 917
new Random() x3 in a loop: 628 423 548
GetItems(3), seed 7    : Middlemarch, Ivanhoe, The Hobbit
Shuffle, seed 7        : Dune, Middlemarch, Ulysses, The Hobbit, Ivanhoe, Emma
Coupon code length 10, only allowed chars: True

Seed verilen Random her seferinde aynı diziyi üretir; testlerde ve tekrar üretilebilir simülasyonlarda istediğin budur. Seed vermeden döngüde oluşturulan Random'lar .NET Core'da farklı değerler veriyor. .NET Framework'te ise seed sistem saatinden alındığı için aynı milisaniyede oluşturulan instance'lar aynı diziyi üretirdi; eski kodda "her seferinde aynı kitap öne çıkıyor" hatasının sebebi buydu. .NET 8 ile gelen GetItems bir diziden tekrar edebilen rastgele seçim yapar, Shuffle diziyi yerinde karıştırır; "günün önerilen kitapları" gibi bir özellik için yeterli.

Kupon kodu, şifre sıfırlama token'ı ya da tahmin edilmemesi gereken her değer için Random değil RandomNumberGenerator kullan. Random'ın algoritması hızlı ama tahmin edilebilir; birkaç çıktıyı gören biri sonrakileri hesaplayabilir. .NET 8'deki RandomNumberGenerator.GetString verilen karakter setinden kriptografik olarak güvenli bir string üretir (örnekte karışan 0/O ve 1/I harflerini setten çıkardık). Kodun kendisini değil, uzunluğunu ve karakter kontrolünü yazdırdık; her koşuda farklı olduğu için. Kriptografik rastgelelik Geliştirici için Kriptografi serisinin ilk iki yazısında.

Üçüncü konu thread güvenliği. Random instance'ının method'ları thread-safe değil. Tek bir instance'ı birden çok thread'den kullanınca ne olduğunu ölçtüm:

Console.WriteLine($"one new Random() from many threads: {DistinctValues(new Random().Next):N0} distinct of 1,000,000");
Console.WriteLine($"Random.Shared from many threads   : {DistinctValues(Random.Shared.Next):N0} distinct of 1,000,000");

var seeded = new Random(42);
int zeros = 0;
Parallel.For(0, 2_000_000, _ =>
{
    if (seeded.Next() == 0)
        Interlocked.Increment(ref zeros);
});
Console.WriteLine($"one new Random(42) from many threads: Next() returned 0 {zeros:N0} times");

static int DistinctValues(Func<int> next)
{
    var seen = new System.Collections.Concurrent.ConcurrentDictionary<int, byte>();
    Parallel.For(0, 1_000_000, _ => seen.TryAdd(next(), 0));
    return seen.Count;
}
one new Random() from many threads: 471,555 distinct of 1,000,000
Random.Shared from many threads   : 999,779 distinct of 1,000,000
one new Random(42) from many threads: Next() returned 0 5 times

Paylaşılan tek bir new Random() bir milyon çağrıda yalnız 471 bin farklı değer üretti; thread'ler aynı iç durumu okuyup aynı sayıyı aldı. Seed'li instance'ın iç durumu bozulunca Next() sıfır döndürmeye başlıyor; bu koşuda 5 kez, bir önceki koşuda iki milyon çağrının 1.968.622'sinde. Sonuç her koşuda farklı, hata da bu yüzden testte yakalanmaz. .NET 6 ile gelen Random.Shared thread-safe; uygulama genelinde rastgele sayı gerekiyorsa onu kullan, seed gerekiyorsa her thread'e kendi instance'ını ver.

.NET 11: Random.NextInteger<T> ve NextBinaryFloat<T>

.NET 11 Random'a generic math tabanlı iki method ekledi: NextInteger<T> IBinaryInteger<T> uygulayan her tamsayı tipi için, NextBinaryFloat<T> ise float, double ve Half gibi kayan noktalı tipler için çalışıyor:

var seeded = new Random(2026);

long orderSuffix = seeded.NextInteger<long>(1_000_000L, 10_000_000L);
byte shelf = seeded.NextInteger<byte>((byte)10);
UInt128 big = seeded.NextInteger<UInt128>();
float weight = seeded.NextBinaryFloat<float>();
NextInteger<long>   : 2272414
NextInteger<byte>   : 6
NextInteger<UInt128>: 229278156830368596334367352717911969374
NextBinaryFloat<float>: 0.80737674

Daha önce long için NextInt64, byte için (byte)Next(10), UInt128 için iki NextInt64 çağrısını birleştiren elle yazılmış kod gerekiyordu. Yeni method'lar generic bir yardımcının içinde de kullanılabiliyor.

Guid v4 ve v7: Sipariş için Zamana Göre Sıralı Id

Guid.NewGuid() RFC (Request for Comments; internet standartlarının yayımlandığı belge serisi) 9562'deki version 4 UUID (Universally Unique Identifier; dağıtık sistemlerde koordinasyonsuz üretilen 128 bitlik kimlik) üretir: 122 biti rastgele. GUID (Globally Unique Identifier; Microsoft'un aynı yapıya verdiği ad) ile UUID aynı şeydir. .NET 9 ile gelen Guid.CreateVersion7() ise version 7 üretir: ilk 48 bit Unix zaman damgası (milisaniye), geri kalanı rastgele. Sonuç, oluşturulma sırasına göre sıralanabilen bir id:

Guid v4 = Guid.NewGuid();
Guid v7 = Guid.CreateVersion7();
Console.WriteLine($"NewGuid        {v4}  Version={v4.Version}");
Console.WriteLine($"CreateVersion7 {v7}  Version={v7.Version}");

DateTimeOffset placed = new(2026, 11, 14, 9, 5, 7, TimeSpan.Zero);
Guid[] ids = [.. Enumerable.Range(0, 5).Select(i => Guid.CreateVersion7(placed.AddMilliseconds(i)))];
foreach (Guid id in ids)
    Console.WriteLine($"{id}  -> {ExtractTimestamp(id):yyyy-MM-dd HH:mm:ss.fff}");

bool sortedAsGuid = ids.SequenceEqual(ids.Order());
bool sortedAsString = ids.Select(g => g.ToString()).SequenceEqual(ids.Select(g => g.ToString()).Order(StringComparer.Ordinal));
Console.WriteLine($"Order() keeps creation order: {sortedAsGuid}, string order keeps it: {sortedAsString}");

Guid[] sameMillisecond = [.. Enumerable.Range(0, 5).Select(_ => Guid.CreateVersion7(placed))];
Console.WriteLine($"Five ids in the same millisecond keep creation order: {sameMillisecond.SequenceEqual(sameMillisecond.Order())}");

// Version 7 UUID'nin ilk 48 biti milisaniye cinsinden Unix zamanıdır (big-endian).
public static DateTimeOffset ExtractTimestamp(Guid id)
{
    Span<byte> bytes = stackalloc byte[16];
    id.TryWriteBytes(bytes, bigEndian: true, out _);
    long milliseconds = 0;
    for (int i = 0; i < 6; i++)
        milliseconds = (milliseconds << 8) | bytes[i];
    return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
}
NewGuid        401511c2-b385-43d5-9e5a-01a56b63028c  Version=4
CreateVersion7 01a1dc54-398a-74a4-8e15-8561f89beae0  Version=7
01a1d94d-a5b8-7477-9300-2f434a0d9bbd  -> 2026-11-14 09:05:07.000
01a1d94d-a5b9-7323-82bf-47ca811071e0  -> 2026-11-14 09:05:07.001
01a1d94d-a5ba-7944-bf3b-fae87bda80ea  -> 2026-11-14 09:05:07.002
01a1d94d-a5bb-75e9-aeb6-93031cee5d19  -> 2026-11-14 09:05:07.003
01a1d94d-a5bc-7a8b-845e-6b097aee50a4  -> 2026-11-14 09:05:07.004
Order() keeps creation order: True, string order keeps it: True
Five ids in the same millisecond keep creation order: False

Version numarası string'in üçüncü grubunun ilk karakterinde görünüyor (4 ve 7); .NET 9 bunu Version property'si olarak da veriyor. CreateVersion7(DateTimeOffset) overload'u testte sabit bir zaman vermeni sağlıyor. Milisaniyeler farklıyken hem Guid'in kendi karşılaştırması hem string sıralaması oluşturma sırasını koruyor. Aynı milisaniye içinde üretilen id'ler ise sıralı değil: .NET'in uygulaması zaman damgasından sonraki bitleri sayaçla değil rastgele dolduruyor. Saniyede binlerce sipariş üreten bir sistemde v7 "yaklaşık sıralı" bir id'dir, kesin sıra için ayrı bir sıra numarası gerekir.

v7'nin asıl vaadi veritabanı index'inde rastgele araya eklemeyi azaltmak. Ama bu vaat veritabanının Guid'i nasıl sıraladığına bağlı: SQL Server uniqueidentifier'ı son 6 byte'tan başlayarak karşılaştırdığı için v7, SQL Server'da v4'ten daha iyi bir clustered index vermiyor. Ölçümler Çözüm Desenleri serisinin "Id'yi Dışarıya Açmak: int Identity, GUID v7 ve Public Id Ayrımı" yazısında; id stratejilerinin karşılaştırması ise System Design serisinin "Unique ID Üretimi" yazısında. Bookstore'da sipariş için kullanıcıya gösterilen numara (BS-2026-0001234) ayrı bir sequence'ten gelir, v7 ise servisler arası mesajlarda ve log korelasyonunda kullanılan teknik kimliktir.

Taşma, checked ve BigInteger

C#'ta tamsayı aritmetiği varsayılan olarak unchecked'dır: taşma exception üretmez, sayı sessizce sarar.

int unitsSold = int.MaxValue;
int next = unitsSold + 1;
Console.WriteLine($"int.MaxValue + 1 (unchecked) = {next}");

try
{
    int boom = checked(unitsSold + 1);
    Console.WriteLine(boom);
}
catch (OverflowException ex)
{
    Console.WriteLine($"checked: {ex.GetType().Name}: {ex.Message}");
}

Console.WriteLine($"Math.BigMul(int.MaxValue, int.MaxValue) = {Math.BigMul(int.MaxValue, int.MaxValue)}");
Console.WriteLine($"Int128.MaxValue = {Int128.MaxValue}");

BigInteger permutations = 1;
for (int i = 2; i <= 30; i++)
    permutations *= i;
Console.WriteLine($"30! shelf orderings = {permutations:N0}");
Console.WriteLine($"BigInteger.Pow(2, 100) = {BigInteger.Pow(2, 100)}");
Console.WriteLine($"Parse 978044117271978044117271 + 1 = {BigInteger.Parse("978044117271978044117271", Inv) + 1}");
int.MaxValue + 1 (unchecked) = -2147483648
checked: OverflowException: Arithmetic operation resulted in an overflow.
Math.BigMul(int.MaxValue, int.MaxValue) = 4611686014132420609
Int128.MaxValue = 170141183460469231731687303715884105727
30! shelf orderings = 265,252,859,812,191,058,636,308,480,000,000
BigInteger.Pow(2, 100) = 1267650600228229401496703205376
Parse 978044117271978044117271 + 1 = 978044117271978044117272

Satış adedi gibi büyüyebilen sayaçlarda checked blok ya da projede <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> ayarı taşmayı sessiz bir negatif sayı yerine görünür bir hataya çevirir. Math.BigMul iki int'in çarpımını taşmadan long olarak verir. .NET 7'deki Int128 sabit genişlikte 128 bit, BigInteger ise sınırsız büyüklükte tamsayıdır; 30 kitabın rafa kaç farklı sırayla dizilebileceği (30!) gibi değerler için kullanılır. BigInteger her işlemde yeni bellek ayırır ve long'dan çok daha yavaştır; para için değil, kombinatorik ve kriptografik hesaplar için düşün. decimal'ın kendi aritmetiği zaten taşmada OverflowException atar.

Üretimde Dikkat

Yuvarlama kuralını kodun her yerine dağıtma. Bookstore'da tutarı yuvarlayan tek bir yardımcı (ör. Money.Round) ve tek bir MidpointRounding değeri olmalı; muhasebe ya da vergi kuralı değiştiğinde tek yeri değiştirirsin. Aynı kural veritabanında da geçerli olmalı: SQL Server'ın ROUND fonksiyonu sıfırdan uzağa yuvarlar, uygulama ToEven ile yuvarlarsa raporlar ile faturalar tutmaz.

.NET 11'e geçerken double'dan decimal'a dönüşen kodu ara. Değişiklik derleme hatası ya da uyarı üretmiyor, yalnız sonucun son basamaklarını değiştiriyor. (decimal) cast'lerini ve Convert.ToDecimal(double) çağrılarını bulup kaynağı decimal'a çevirmek, geçişten önce yapılacak işler listesine girmeli.

Rastgelelik testleri zorlaştırır. Rastgele seçim yapan kodu test edilebilir yapmak için Random'ı constructor'dan al ve testte seed'li bir instance ver; aynı yaklaşım zaman için TimeProvider ile yapılır ve serinin 6. yazısında anlatılıyor. Guid.CreateVersion7 de zamana bağlı olduğu için testte DateTimeOffset alan overload'unu kullan.

Sık Düşülen Tuzaklar

Math.Round(2.5) == 2 sürprizi. Varsayılan mod ToEven; para için modu her çağrıda açıkça ver.

Format ile Math.Round'un aynı kuralı uyguladığını varsaymak. decimal formatları sıfırdan uzağa, Math.Round çifte yuvarlar; önce yuvarla, sonra göster.

Para hesabını double ile yapmak. Üç kitabın toplamı 1049,6999999999998 olur; .NET 11'de double'dan çevrilen fiyat da 349,89999999999997726263245568.

Tek bir Random instance'ını thread'ler arasında paylaşmak. Değerler tekrarlanır ya da sıfıra döner; Random.Shared kullan. Güvenlik gerektiren değerde Random'ın hiçbir türünü değil RandomNumberGenerator'ı kullan.

v7'nin her veritabanında sıralı index sağladığını düşünmek. SQL Server'ın uniqueidentifier sıralaması bunu bozar; aynı milisaniyede üretilen v7'ler de kendi aralarında sıralı değildir.

Küçük Bir Deneme

★☆☆ numbers-rounding örneğine -2.345m değerini ekle ve iki basamağa beş modla yuvarla. ToNegativeInfinity ile decimal.Floor'un aynı sonucu verdiği durumu bul.

★★☆ Kampanya örneğine bir dağıtma adımı ekle: indirimi ara toplamda hesapla, satırlara oransal dağıt, yuvarlama farkını en büyük kalanı olan satıra yükle ve satır indirimlerinin toplamının ara toplam indirimine eşit olduğunu doğrula.

★★★ Sipariş numarası için BS- ön ekli, 10 karakterlik, karışan harfleri içermeyen ve tahmin edilemeyen bir kod üreten OrderCodeGenerator yaz. Bir milyon kod üretip çakışma sayısını ölç ve doğum günü probleminin (aynı uzaydan rastgele seçilen değerlerin ne zaman çakışmaya başladığını hesaplayan olasılık problemi) tahminiyle karşılaştır.

Sayıları bitirdik. Bir sonraki yazıda byte'lara geçiyoruz: Encoding.UTF8 ve BOM (Byte Order Mark; dosyanın başına konan kodlama işareti), eski muhasebe sisteminden gelen Windows-1254 kodlu alanlar, Base64 ve Base64Url, hex dönüşümü ve "Şeker Portakalı" aramasının URL'ini string birleştirmeden doğru kurmak.