Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri Number System'ler ve Data Representation — #03

Integer Temsili: Two's Complement, Overflow, Endianness ve JSON'da Kaybolan long ID

Mert Özen 14 Kas 2026 12 dk 4 görüntülenme
Integer Temsili: Two's Complement, Overflow, Endianness ve JSON'da Kaybolan long ID

Bilgisayar negatif integer'ları two's complement ile tutar: bitleri çevirip 1 eklersin, böylece tek bir toplayıcı hem toplar hem çıkarır ve tek bir sıfır kalır. int.MinValue asimetrisi ve Math.Abs exception'ı, wrap-around, byte'tan Int128'e genişlikler, endianness ve BinaryPrimitives, 2^53'ü geçen long ID'nin JSON'da sessizce değişmesi, Y2038 ve tükenen int identity.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (C# 14, ASP.NET Core 10), TypeScript 7.0, Node.js 24 ve SQL Server 2025 ile denenmiştir; C# örneklerinin testleri xUnit v3 4.0.3 ile yazılmıştır.

Önce Tahmin Et

Bookstore'un yeni katalog servisi kitap ID'lerini 64 bitlik long olarak üretiyor. API, ID'si 9007199254740993 olan kitabı JSON olarak döndürüyor; Angular uygulaması bu kitabın yorumlarını almak için /books/{id}/reviews adresine istek atıyor. İstek hangi ID ile gider?

İkinci soru daha kısa: Math.Abs(int.MinValue) ne döndürür? 2147483648 mi, -2147483648 mi, yoksa hiçbiri mi?

Serinin önceki yazısında, "Bitwise İşlemler" yazısında, -16'nın bit düzeninin 11111111111111111111111111110000 olduğunu görmüş ama nedenini sonraya bırakmıştık. Bu yazı o soruyla başlıyor; iki sorunun cevabı da aynı temsilde.

Negatif Sayıyı Bitlerle Yazmanın Üç Yolu

Unsigned bir sayıda her bit pozitif bir değer taşır; 8 bitle 0–255 arası yazılır. Negatif sayılar için bitlerden birini bir şekilde "işaret" olarak kullanmak gerekir. Tarihte üç yöntem denendi:

public static string Bits8(int value) => Convert.ToString(value & 0xFF, 2).PadLeft(8, '0');

public static int SignMagnitude(int value) => value < 0 ? 0x80 | -value : value;

public static int OnesComplement(int value) => value < 0 ? ~(-value) & 0xFF : value;

public static int TwosComplement(int value) => value & 0xFF;
value  sign-magnitude  one's complement  two's complement
    5  00000101        00000101          00000101
   -5  10000101        11111010          11111011
    0  00000000        00000000          00000000
  127  01111111        01111111          01111111
 -127  11111111        10000000          10000001
 -128  -               -                 10000000

Sign-magnitude en sezgisel olanı: en soldaki bit işaret, kalan 7 bit büyüklük. One's complement negatif sayıyı pozitifinin bütün bitlerini çevirerek yazar. Two's complement ise bitleri çevirip 1 ekler. Pozitif sayılarda üçü aynı; fark yalnızca negatiflerde.

İlk iki yöntemin ortak sorunu iki ayrı sıfır olması. Sign-magnitude'da 10000000, one's complement'te 11111111 "eksi sıfır"dır. Her karşılaştırmada iki sıfırı eşit saymak için ek mantık gerekir. Daha büyük sorun toplamada çıkar: bu iki temsilde 5 + (-5) işlemini yapmak için donanımın işaretlere bakıp ayrı bir yol izlemesi gerekir.

Two's Complement Neden Kazandı: Tek Toplayıcı, Tek Sıfır

Two's complement'te negatif sayı, unsigned toplamanın "taşmasından" yararlanır. 8 bitte -5 = 11111011, unsigned olarak 251. 5 ile toplayınca 256 olur; 256 dokuz bit ister, en üstteki bit atılır ve geriye 0 kalır:

byte five = 5;
byte minusFiveBits = 0b1111_1011;
Console.WriteLine(unchecked((byte)(five + minusFiveBits)));          // 0
Console.WriteLine(unchecked((sbyte)minusFiveBits));                  // -5
Console.WriteLine(~5 + 1);                                           // -5
byte three = 3;
Console.WriteLine(unchecked((sbyte)(byte)(three + minusFiveBits)));  // -2

Bu kodun önemli tarafı şu: toplamayı yapan işlem unsigned toplamayla birebir aynı. Donanım "bu sayı signed mı" diye sormadan aynı toplayıcıyla hem 3 + 251 = 254'ü hem 3 + (-5) = -2'yi hesaplar; sonucu nasıl yorumlayacağına sen karar verirsin. Çıkarma da ayrı bir devre gerektirmez: a - b = a + (~b + 1). Sıfır tek bir tane, 00000000. Bedeli küçük bir asimetri: 8 bitte en küçük sayı -128, en büyük 127. Pozitif tarafta bir eksik var, çünkü sıfır pozitif taraftan bir yer kaplıyor.

Bunu bir çember gibi düşünmek en kolayı. 256 değer bir saatin kadranı gibi dizilmiş; sağ yarıda 0'dan 127'ye işaret biti 0 olan sayılar, sol yarıda -128'den -1'e işaret biti 1 olanlar:

03-twos-complement-circle-tr.png

-1'den bir adım ileri gidince 0'a gelirsin; doğru olan budur. 127'den bir adım ileri gidince ise -128'e gelirsin; overflow budur. 32 ve 64 bitte çember daha büyüktür ama şekli aynıdır.

Overflow: Çemberin Ötesine Geçmek

C#'ta integer aritmetiği varsayılan olarak unchecked çalışır, yani taşma olduğunda çember etrafında dönülür ve exception fırlatılmaz. checked ile bunu nasıl değiştireceğin C# Temelleri serisinin "C# Sayılar ve Aritmetik: int, double, decimal ve Overflow" yazısında anlatıldı. Burada aynı olayın bit düzeyine bakıyoruz:

int max = int.MaxValue;
int wrapped = unchecked(max + 1);
// 2147483647   01111111111111111111111111111111
// -2147483648  10000000000000000000000000000000

Toplama yalnızca en alttaki 31 biti taşıyıp işaret bitini 1 yaptı. İşlemci açısından hiçbir şey olmadı; yalnızca yorum değişti.

Asimetrinin bedeli int.MinValue'da ödenir. -2147483648'in pozitif karşılığı 32 bitlik signed bir sayıya sığmaz:

int min = int.MinValue;
Console.WriteLine(unchecked(-min));   // -2147483648

int minusOne = -1;
try { Console.WriteLine(unchecked(min / minusOne)); }
catch (OverflowException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
// OverflowException: Arithmetic operation resulted in an overflow.

try { Console.WriteLine(Math.Abs(min)); }
catch (OverflowException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
// OverflowException: Negating the minimum value of a twos complement number is invalid.

Console.WriteLine(Math.Abs((long)min));   // 2147483648

İkinci sorunun cevabı: Math.Abs(int.MinValue) exception fırlatır. -min sessizce yine int.MinValue verir; ~x + 1 kuralını uygularsan 10000000… çevrilince 01111111…, 1 eklenince yine 10000000… olur. int.MinValue / -1 ise unchecked içinde bile OverflowException fırlatır. C# spesifikasyonu unchecked bağlamda bu durumu implementation-defined bırakıyor (exception da olabilir, sessiz sonuç da); .NET runtime'ı exception fırlatıyor.

Bu, gerçek hayatta en çok hash ile bucket hesaplarken karşına çıkar. Math.Abs(hash) % buckets 4 milyarda bir kez, hash tam int.MinValue geldiğinde patlar. Ayrıca % operatörü C#'ta negatif sonuç verebilir (-17 % 16 = -1), bu yüzden Math.Abs'ı atlamak da çözüm değil. Doğru yol bitleri unsigned olarak yorumlamak:

// Yanlış: Math.Abs(int.MinValue) exception fırlatır
public static int BucketNaive(int hash, int buckets) => Math.Abs(hash) % buckets;

// Doğru: unsigned olarak yorumla, sonuç hiçbir zaman negatif olmaz
public static int Bucket(int hash, int buckets) => (int)((uint)hash % (uint)buckets);

Genişlik: byte'tan Int128'e

Aynı mantık her genişlikte geçerli; değişen yalnızca çemberin büyüklüğü:

TipBitAralık
sbyte / byte8-128 … 127 / 0 … 255
short / ushort16-32768 … 32767 / 0 … 65535
int / uint32yaklaşık ±2,1 milyar / 0 … 4294967295
long / ulong64yaklaşık ±9,2 × 10^18 / 0 … 18446744073709551615
Int128 / UInt128128yaklaşık ±1,7 × 10^38 / 0 … 3,4 × 10^38
nint / nuint32 veya 64process'in pointer genişliği

Int128 ve UInt128 .NET 7 ile geldi; Int128.MaxValue 170141183460469231731687303715884105727. nint ise çalıştığı process'in pointer genişliğini alır; 64 bitlik bir process'te nint.Size 8'dir. Interop ve düşük seviye bellek kodu dışında nint'e ihtiyacın olmaz.

Endianness: Byte'lar Hangi Sırayla Yazılır

32 bitlik bir sayı bellekte dört byte kaplar. Hangi byte'ın önce geleceği bir tercih. Little endian'da en düşük değerli byte önce, big endian'da en yüksek değerli byte önce yazılır. x64 ve Windows'un çalıştığı Arm64 sistemleri little endian'dır:

Console.WriteLine(BitConverter.IsLittleEndian);                          // True
int value = 0x0A0B0C0D;
Console.WriteLine(BitConverter.ToString(BitConverter.GetBytes(value)));  // 0D-0C-0B-0A

byte[] buffer = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(buffer, value);
Console.WriteLine(BitConverter.ToString(buffer));                        // 0A-0B-0C-0D
Console.WriteLine(BinaryPrimitives.ReadInt32BigEndian(buffer).ToString("X8"));     // 0A0B0C0D
Console.WriteLine(BinaryPrimitives.ReadInt32LittleEndian(buffer).ToString("X8"));  // 0D0C0B0A

BitConverter her zaman makinenin kendi sırasını kullanır. Bir dosya formatı ya da ağ protokolü belirli bir sıra tanımlıyorsa System.Buffers.Binary.BinaryPrimitives kullan; method adı sırayı açıkça söyler ve hangi makinede çalışırsa çalışsın aynı sonucu verir.

Ağ protokolleri big endian kullanır; buna network byte order denir. PNG (kayıpsız sıkıştırma kullanan bir resim formatı) dosyaları da öyle. İlk yazıda baktığımız cover.png'nin 16. byte'ından itibaren resmin genişliği ve yüksekliği 4'er byte olarak yazılı:

byte[] png = File.ReadAllBytes("cover.png");
Console.WriteLine(BitConverter.ToString(png, 16, 8));   // 00-00-07-08-00-00-05-00
Console.WriteLine($"{BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(16))} x {BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(20))}");
// 1800 x 1280
Console.WriteLine($"{BitConverter.ToInt32(png, 16)} x {BitConverter.ToInt32(png, 20)}");
// 134676480 x 327680

Doğru sırayla okuyunca 1800 × 1280, makinenin sırasıyla okuyunca 134 milyon piksel genişliğinde bir resim. Bu tür bir hata exception fırlatmaz; yanlış ama geçerli görünen bir sayı üretir.

.NET'te sıkça karşılaşılan karışık bir durum da Guid. Metin hâlindeki ilk üç grup, struct içinde little endian sayılar olarak durduğu için ToByteArray bunları ters sırayla verir:

Guid id = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff");
Console.WriteLine(Convert.ToHexString(id.ToByteArray()));                  // 33221100554477668899AABBCCDDEEFF
Console.WriteLine(Convert.ToHexString(id.ToByteArray(bigEndian: true)));   // 00112233445566778899AABBCCDDEEFF

GUID'leri (Globally Unique Identifier; dünya genelinde benzersiz olması amaçlanan 128 bitlik kimlik) byte olarak başka bir dilin UUID (Universally Unique Identifier; aynı yapının standartlardaki ve diğer dillerdeki adı) tipine aktarıyorsan .NET 8 ile gelen bigEndian: true parametresini kullan; aksi hâlde iki taraf aynı ID'yi farklı metinle gösterir.

JSON'da Kaybolan long ID

Şimdi ilk sorunun cevabı. Katalog servisi ASP.NET Core Minimal API ile yazılmış:

app.MapGet("/books/{id:long}", (long id) => new BookDto(id, "Dune"));

public record BookDto(long Id, string Title);
$ curl -i http://localhost:5731/books/9007199254740993
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id":9007199254740993,"title":"Dune"}

Sunucu tarafında her şey doğru: JSON'daki sayı tam olarak 9007199254740993. JSON formatı bir sayının basamak sayısına sınır koymaz; JSON'u tanımlayan RFC (Request for Comments; internet standartlarının yayımlandığı doküman serisi) 8259 yalnızca, birlikte çalışabilirlik için double hassasiyetinin ötesine güvenmemeyi öneriyor. Sorun okuyan tarafta. JavaScript'te number 64 bitlik bir floating point değer ve integer'ları ancak 2^53'e kadar kayıpsız tutabiliyor; nedenini serinin sonraki yazısında göreceğiz. Angular'ın HttpClient'ı da JSON yanıtını JSON.parse ile okuduğu için aynı şeyi yaşar:

interface Book {
  id: number;
  title: string;
}

const response = await fetch("http://localhost:5731/books/9007199254740993");
const book = (await response.json()) as Book;
console.log(book.id);                                                   // 9007199254740992
console.log(Number.MAX_SAFE_INTEGER, Number.isSafeInteger(book.id));    // 9007199254740991 false
console.log(`/books/${book.id}/reviews`);                               // /books/9007199254740992/reviews
console.log(2 ** 53, 2 ** 53 + 1, 2 ** 53 + 2);                         // 9007199254740992 9007199254740992 9007199254740994

ID bir eksiğiyle geldi ve istek başka bir kitaba gitti. Hiçbir yerde hata yok, log'da uyarı yok; o ID'de bir kitap varsa kullanıcı yanlış kitabın yorumlarını görür, yoksa 404 alır. Number.MAX_SAFE_INTEGER 2^53 − 1; bunun üstünde iki ardışık integer aynı number'a düşebilir.

Çözüm seçenekleri ve bedelleri:

ID'yi JSON'da string olarak yaz. System.Text.Json bunu property başına destekliyor:

public record BookDtoV2(
    [property: JsonNumberHandling(JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString)]
    long Id,
    string Title);
$ curl http://localhost:5731/v2/books/9007199254740993
{"id":"9007199254740993","title":"Dune"}

Bu bir contract değişikliği: mevcut istemciler id'yi number bekliyor. Yeni bir API sürümüyle ya da yeni bir alanla geçiş yapman gerekir. Aynı ayarı ConfigureHttpJsonOptions ile global yapmak cazip görünür ama fiyat, stok gibi bütün sayıları da string'e çevirir; bu ayarı yalnızca ID'lere uygula.

Frontend'de ID'yi string olarak modelle. ID bir miktar değil, bir isim; üzerinde toplama yapmazsın. TypeScript tarafında id: string doğru tip; gerçekten aritmetik gerekiyorsa BigInt(v2.id) + 1n → 9007199254740994n.

Sayıyı BigInt olarak parse et. TC39 (JavaScript dilini standartlaştıran Ecma komitesi) sürecinde Stage 4'e ulaşan JSON.parse source text access özelliği, reviver fonksiyonuna sayının JSON'daki orijinal metnini veriyor ve Node.js 24'te çalışıyor:

const raw = '{"id":9007199254740993,"title":"Dune"}';
const parsed = JSON.parse(raw, function (key, value, context?: { source?: string }) {
  return key === "id" && context?.source !== undefined ? BigInt(context.source) : value;
});
console.log(parsed.id);   // 9007199254740993n

Bunun için yanıtı metin olarak alıp kendin parse etmen gerekiyor. Ayrıca JSON.stringify BigInt içeren bir object'te TypeError: Do not know how to serialize a BigInt fırlatıyor; geri gönderirken de özel bir dönüşüm gerekiyor.

Bir ayrıntı daha: veritabanının ürettiği bigint identity değerleri pratikte 2^53'e hiç ulaşmaz. Sorun, bitleri bir araya getirilerek üretilen ID'lerde çıkar: zaman damgasını üst bitlere, makine numarasını ve sırayı alt bitlere yerleştiren Snowflake tarzı ID'ler veya rastgele 64 bitlik ID'ler en baştan 2^53'ün üstündedir. Bu ID'leri dışarı açan her API'de string seçeneğini baştan düşün.

Diğer Gerçek Vakalar: Y2038, Sayaçlar ve Identity

Unix zamanı 1970-01-01 UTC'den (Coordinated Universal Time; dünya genelinde referans alınan saat) bu yana geçen saniye sayısıdır. Bunu 32 bitlik signed bir sayıda tutan sistemler 2038'de çemberin ötesine geçer:

Console.WriteLine(DateTimeOffset.FromUnixTimeSeconds(int.MaxValue).ToString("u"));               // 2038-01-19 03:14:07Z
Console.WriteLine(DateTimeOffset.FromUnixTimeSeconds(unchecked(int.MaxValue + 1)).ToString("u")); // 1901-12-13 20:45:52Z
Console.WriteLine(DateTimeOffset.FromUnixTimeSeconds(uint.MaxValue).ToString("u"));              // 2106-02-07 06:28:15Z

Bir saniye sonra saat 1901'e döner. .NET'in DateTime'ı bu sorundan etkilenmez, ama zamanı 4 byte'lık bir alanda saklayan dosya formatları, gömülü cihazlar ve eski veritabanı şemaları etkilenir. Kendi sisteminde zamanı int olarak saklayan bir kolon ya da mesaj alanı varsa bunu bugün düzeltmek 2037'de düzeltmekten ucuzdur.

Sayaçlar da aynı şekilde taşar. Sayfa görüntülenme sayacını int tutan bir servis, 2,1 milyarı geçince negatif sayılar gösterir:

int pageViews = int.MaxValue - 1;
for (int i = 0; i < 3; i++)
{
    Interlocked.Increment(ref pageViews);
    Console.WriteLine(pageViews);
}
// 2147483647
// -2147483648
// -2147483647

Interlocked.Increment thread-safe'tir ama taşmaya karşı koruma sağlamaz; dokümantasyonu da wrap-around davranışını açıkça belirtir.

Veritabanında da durum aynı. SQL Server'da int identity kolonu 2147483647'de durur. Bunu taşmaya yakın bir değerle başlatan bir tabloyla gördüm:

CREATE TABLE dbo.Orders
(
    Id int IDENTITY(2147483646, 1) PRIMARY KEY,
    CustomerName nvarchar(100) NOT NULL
);
INSERT INTO dbo.Orders (CustomerName) VALUES (N'Ada');
INSERT INTO dbo.Orders (CustomerName) VALUES (N'Alan');
INSERT INTO dbo.Orders (CustomerName) VALUES (N'Grace');
(1 rows affected)

(1 rows affected)
Msg 8115, Level 16, State 1, Server b8be943222fa, Line 8
Arithmetic overflow error converting IDENTITY to data type int.
Arithmetic overflow occurred.

Bu hatayı production'da gördüğünde artık hiçbir sipariş yazılamaz. Çok satırlı bir tabloda kolonu bigint'e çevirmek uzun süren ve tabloyu kilitleyen bir işlemdir; foreign key'ler ve index'ler de değişir.

Üretimde Dikkat

Büyüyen tabloların identity değerlerini izle. IDENT_CURRENT('dbo.Orders')'ı tipin üst sınırıyla karşılaştıran ve yüzde 70'i geçince uyarı veren basit bir kontrol, migration'ı planlı yapmana zaman kazandırır. Yeni tablolarda satır sayısı milyarlara çıkabilecekse baştan bigint seç; 4 byte'lık fark nadiren önemlidir.

Dışarıya açılan her 64 bitlik ID için JavaScript istemcisini düşün. API tasarım aşamasında "bu ID 2^53'ü geçebilir mi" sorusunu sor; cevap evetse ilk sürümden itibaren string gönder. Sonradan değiştirmek bir breaking change'dir.

Binary veri okuyan veya yazan her kodda sırayı adıyla belirt. BitConverter.ToInt32 yerine BinaryPrimitives.ReadInt32BigEndian veya ReadInt32LittleEndian yaz. Kodu okuyan kişi formatın hangi sırayı kullandığını tahmin etmek zorunda kalmaz.

Taşmanın sessiz olmasını istemediğin hesaplarda checked kullan. Miktar, stok ve para hesaplarında yanlış bir sayıyla devam etmektense exception fırlatmak daha iyidir; hash ve checksum hesaplarında ise wrap-around zaten istenen davranıştır.

Sık Düşülen Tuzaklar

Math.Abs sonucunun her zaman pozitif olduğunu varsaymak. int.MinValue için exception fırlatır; -x ise sessizce negatif kalır. Hash, rastgele sayı veya dışarıdan gelen bir değerle çalışıyorsan bu uç durumu test et.

% operatörünü matematikteki mod sanmak. C# ve JavaScript'te sonuç bölünenin işaretini alır: -17 % 16 = -1. Negatif değerlerle array index'i hesaplıyorsan ((x % n) + n) % n ya da unsigned cast kullan.

Testlerde yalnızca küçük ID'ler kullanmak. Test verisindeki ID'ler 1, 2, 3 olduğu sürece JSON hassasiyet kaybı, 32 bit bitwise işlemler ve int taşmaları hiç görünmez. Her sayı tipinin sınırına yakın değerlerle (int.MaxValue, 2^53 + 1) en az bir test yaz.

Signed ve unsigned arasında dikkatsiz cast. (uint)-1 4294967295 olur; bir "kalan adet" değeri yanlışlıkla negatif hesaplanıp uint'e çevrilirse 4 milyarlık bir stok görürsün. Cast'ten önce aralığı kontrol et ya da checked kullan.

Küçük Bir Deneme

★☆☆ 16 bitlik short için -1, -32768 ve 32767'nin bit düzenini elle yaz, sonra Convert.ToString((ushort)x, 2) ile kontrol et. short.MinValue'nun neden pozitif karşılığı olmadığını çember üzerinde göster.

★★☆ BinaryPrimitives ile bir PNG dosyasının bütün chunk'larını listeleyen bir program yaz: her chunk 4 byte'lık big endian uzunluk, 4 byte'lık tip (IHDR, IDAT, IEND…), veri ve 4 byte'lık CRC'den (Cyclic Redundancy Check; verinin bozulmadığını kontrol etmek için hesaplanan değer) oluşur. cover.png için çıktın IEND ile bitmeli.

★★★ Bookstore API'sine 2^53 + 1 ID'li bir kitap ekleyip Angular tarafında hem number hem string ID ile çalışan iki servis yaz. Birinin yanlış kitabı getirdiğini gösteren bir test yaz, sonra API'yi ID'yi string gönderecek şekilde yeni bir sürümle değiştir.

Bu yazıda integer'ların bitlerini gördük ve JavaScript'in 2^53'te neden durduğunu sonraya bıraktık. Serinin sonraki yazısı "Floating Point Nasıl Çalışır" bu sınırın kaynağını açıklıyor: bir double'ın 64 bitinin sign, exponent ve mantissa olarak nasıl bölündüğünü, 0.1'in neden tam yazılamadığını ve NaN'ın neden kendine eşit olmadığını göreceğiz.