Floating Point Nasıl Çalışır: IEEE 754, 0.1 + 0.2, NaN ve double vs decimal
0.1 + 0.2, 0.3 etmez çünkü double sayıları 2 tabanında sign, exponent ve 52 bitlik mantissa ile tutar ve 0.1 bu tabanda tam yazılamaz. binary64'ün bit düzeni, sayılar büyüdükçe açılan boşluklar, ±0, NaN ve subnormal, relative tolerance ile karşılaştırma, Half/float/double/decimal farkı, .NET 9 ve .NET 11'in saturating dönüşümleri, banker's rounding ve culture'a bağlı parse.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (C# 14), TypeScript 7.0 ve Node.js 24 ile derlenip denenmiştir; dönüşüm karşılaştırması ayrıca .NET 11 (11.0.100 SDK) ile çalıştırılmıştır. C# örneklerinin testleri xUnit v3 4.0.3 ile yazılmıştır. Yazı IEEE (Institute of Electrical and Electronics Engineers; elektrik ve elektronik alanında standart yayımlayan kuruluş) 754-2019 standardını esas alır.
Önce Tahmin Et
Bookstore sepetinde 0.1 ve 0.2 TL'lik iki dijital ürün var ve kod tutarı double ile topluyor. 0.1 + 0.2 == 0.3 ne döndürür? Bunu çoğu kişi bilir. Peki şu döngü bitince f kaç olur?
float f = 0;
for (int i = 0; i < 20_000_000; i++) f += 1.0f;
20 milyon mu? Biraz eksik ya da fazla mı? Bir de üçüncü soru: double x = 300.0; iken (byte)x kaç? 255 mi, 44 mü, exception mı? Cevap .NET sürümüne göre değişiyor.
Serinin önceki yazısında, "Integer Temsili" yazısında, JavaScript'in integer'ları neden 2^53'ten sonra kaybettiğini sonraya bırakmıştık. Üç sorunun da cevabı bir double'ın 64 bitini nasıl kullandığında.
64 Bit, Üç Alan: Sign, Exponent, Mantissa
Floating point sayıların bit düzenini IEEE 754 standardı tanımlar; güncel sürümü 2019 tarihli. C#'taki double ve JavaScript'teki number standardın binary64 formatıdır. Fikir bilimsel gösterimle aynı: 6,02 × 10^23 gibi, ama taban 2. 64 bit üç alana bölünür: 1 bit sign, 11 bit exponent, 52 bit mantissa (standartta significand diye geçer).
public static (int Sign, int Exponent, long Mantissa) Split(double value)
{
long bits = BitConverter.DoubleToInt64Bits(value);
int sign = (int)(bits >>> 63);
int exponent = (int)((bits >> 52) & 0x7FF);
long mantissa = bits & 0xF_FFFF_FFFF_FFFF;
return (sign, exponent, mantissa);
}
0.1 3FB999999999999A sign=0 exp=1019 (-4) mantissa=999999999999A
0.2 3FC999999999999A sign=0 exp=1020 (-3) mantissa=999999999999A
0.3 3FD3333333333333 sign=0 exp=1021 (-2) mantissa=3333333333333
0.30000000000000004 3FD3333333333334 sign=0 exp=1021 (-2) mantissa=3333333333334
1 3FF0000000000000 sign=0 exp=1023 (0) mantissa=0000000000000
-2.5 C004000000000000 sign=1 exp=1024 (1) mantissa=4000000000000
Exponent negatif değerleri de tutabilsin diye 1023 eklenerek (bias) saklanır: 1019 aslında 2^-4 demek. Mantissa, sayının 1 ile 2 arasındaki kısmının kesirli tarafıdır. Normalize edilmiş bir sayının başı her zaman 1. olduğu için o 1 yazılmaz, böylece 52 bitle 53 bitlik hassasiyet elde edilir. 1.0'ın mantissa'sı bu yüzden sıfır. -2.5 = -1.25 × 2^1: sign 1, exponent 1023 + 1, mantissa 0.25'in binary karşılığı. float (binary32) aynı yapıda 1 + 8 + 23 bit kullanır ve bias'ı 127'dir.

0.1 Neden Tam Yazılamaz
Onluk sistemde 1/3 sonsuza kadar süren 0.333… olur, çünkü 3, 10'un bir böleni değil. İkilik sistemde aynı şey 1/10 için geçerli: 10'un içinde 5 çarpanı var ve 5, 2'nin bir böleni değil. 0.1'in binary açılımı 0.0001100110011… diye sonsuza kadar gider. 52 bitte bir yerde kesilmesi gerekir; mantissa'daki 999…9A'nın sonundaki A bu yuvarlamanın izi. Saklanan değerin tam karşılığını görmek mümkün:
Console.WriteLine(0.1.ToString("F55")); // 0.1000000000000000055511151231257827021181583404541015625
Console.WriteLine(0.1.ToString("G17")); // 0.10000000000000001
Console.WriteLine(0.1 + 0.2); // 0.30000000000000004
Console.WriteLine(0.1 + 0.2 == 0.3); // False
Console.WriteLine(0.1m + 0.2m == 0.3m); // True
Ekranda "0.1" görmemizin nedeni .NET Core 3.0'dan beri ToString'in, tekrar parse edildiğinde aynı double'ı veren en kısa metni yazması. Bellekte duran sayı 0.1'den biraz büyük. 0.2 de biraz büyük; ikisinin toplamı ise 0.3'e en yakın double'ın bir üstündeki double'a yuvarlanıyor. Yukarıdaki tabloda farkı görebilirsin: ...3333 ile ...3334, yalnızca son bit farklı. double'ın hatası bu: 0.3'ü bilmiyor değil, 0.1 + 0.2'nin sonucu 0.3 olarak saklanan değere eşit değil.
Bu kod hatası değil, temsilin özelliği. David Goldberg'ün 1991 tarihli "What Every Computer Scientist Should Know About Floating-Point Arithmetic" makalesi bu yuvarlama hatalarının matematiğini ayrıntılı olarak anlatıyor; konuya daha derin girmek istersen ilk okuman gereken kaynak o.
Precision ve Range: Sayılar Büyüdükçe Aralar Açılır
53 bitlik mantissa her zaman yaklaşık 15–17 anlamlı onluk basamak demek. Exponent ise bu basamakların nereye yerleşeceğini belirler. Sonuç: büyük sayılarda iki double arasındaki boşluk da büyür.
foreach (double x in new[] { 1.0, 1000.0, 1e6, 1e15, 9007199254740992.0 })
{
Console.WriteLine($"{x,-22:R} next={Math.BitIncrement(x) - x:R}");
}
1 next=2.220446049250313E-16
1000 next=1.1368683772161603E-13
1000000 next=1.1641532182693481E-10
1000000000000000 next=0.125
9007199254740992 next=2
1 civarında iki double arasındaki fark yaklaşık 2,2 × 10^-16; 10^15 civarında 0,125; 2^53'te ise 2. Bu son satır önceki yazıdaki JSON bug'ının açıklaması: 2^53'ten sonra double yalnızca çift sayıları tutabiliyor, 9007199254740993 yazılamıyor ve en yakın değere, 9007199254740992'ye yuvarlanıyor. Range çok geniş (double.MaxValue yaklaşık 1,8 × 10^308) ama hassasiyet her yerde aynı sayıda basamak.
Önce Tahmin Et'teki float döngüsünün cevabı 16777216, yani 2^24. float'ın 24 bitlik hassasiyeti var; 2^24'te iki komşu float arasındaki fark 2 olur. 16777216 + 1 tam ortaya düşer ve "çift olana yuvarla" kuralıyla yine 16777216'ya döner. Döngü 20 milyon kez çalışır ama toplam 16777216'da takılı kalır. Sayaçları ve büyük toplamları float'ta tutmanın riski bu.
Özel Değerler: ±0, Infinity, NaN ve Subnormal
Exponent alanının iki değeri özel anlam taşır. Bütün bitler 1 (2047) ise sayı Infinity veya NaN'dır; bütün bitler 0 ise sıfır ya da subnormal bir sayıdır.
double zero = 0.0, negZero = -0.0;
Console.WriteLine(zero == negZero); // True
Console.WriteLine(1 / zero); // Infinity
Console.WriteLine(1 / negZero); // -Infinity
Console.WriteLine(double.IsNegative(negZero)); // True
double nan = zero / zero;
Console.WriteLine(nan == nan); // False
Console.WriteLine(nan.Equals(nan)); // True
Console.WriteLine(double.IsNaN(nan)); // True
Console.WriteLine(Math.Sqrt(-1)); // NaN
Console.WriteLine(Math.Max(nan, 1)); // NaN
Console.WriteLine(BitConverter.DoubleToInt64Bits(double.PositiveInfinity).ToString("X16")); // 7FF0000000000000
Console.WriteLine(BitConverter.DoubleToInt64Bits(double.Epsilon).ToString("X16")); // 0000000000000001
Sign biti ayrı olduğu için iki sıfır var: +0 ve -0. Karşılaştırmada eşitler, ama bölmede farklı sonsuzluklara götürürler. NaN (Not a Number; tanımsız bir işlemin sonucu) exponent'i 2047, mantissa'sı sıfırdan farklı olan her bit düzenidir. IEEE 754'e göre NaN hiçbir şeye, kendisine bile eşit değildir: nan == nan False döndürür. C# derleyicisi bunu görünce warning CS1718: Comparison made to same variable uyarısı verir. Ama .NET'in Equals method'u NaN'ı kendine eşit sayar; collection'ların çalışabilmesi için bu gerekli. == ve Equals'ın farklı cevap verdiği nadir yerlerden biri.
Exponent sıfır olduğunda baştaki gizli 1 kalkar ve sayı subnormal olur. Bu, sıfıra çok yakın değerlerin aniden sıfıra düşmemesini sağlar. En küçük pozitif double, yani son bit'i 1 olan subnormal, yaklaşık 4,9 × 10^-324'tür ve C#'ta adı double.Epsilon'dır. Dikkat: bu, sayısal analizde "machine epsilon" denen değer değil. 1 ile ondan sonraki double arasındaki fark, yani 2,2 × 10^-16, JavaScript'te Number.EPSILON adını taşıyor. İki dilde aynı ad farklı değerleri gösteriyor.
Karşılaştırma: Neden Sabit Epsilon Yetmez
Double'ları == ile karşılaştırmamak gerektiğini çoğumuz biliyoruz. Yaygın çözüm farkı sabit küçük bir sayıyla karşılaştırmak:
double total = 0;
for (int i = 0; i < 10; i++) total += 0.1;
Console.WriteLine(total); // 0.9999999999999999
Console.WriteLine(Math.Abs(total - 1.0) < 1e-9); // True
double big1 = 1e15 + 0.1, big2 = 1e15 + 0.2;
Console.WriteLine(Math.Abs(big1 - big2) < 1e-9); // False
1 civarında işe yarayan 1e-9, 10^15 civarında anlamsız; orada iki komşu double arasındaki fark bile 0,125. Sabit epsilon ya büyük sayılarda her şeyi "farklı" ya da küçük sayılarda her şeyi "eşit" sayar. Doğru yaklaşım farkı sayıların büyüklüğüyle orantılı ölçmek (relative tolerance), sıfıra yakın değerler için de ayrıca mutlak bir alt sınır tanımlamak:
// Python'daki math.isclose gibi: göreli tolerans, sıfır yakınında mutlak alt sınır
public static bool AlmostEqual(double a, double b, double relTol = 1e-9, double absTol = 0.0)
{
if (a == b)
{
return true;
}
if (double.IsInfinity(a) || double.IsInfinity(b))
{
return false;
}
double diff = Math.Abs(a - b);
return diff <= Math.Max(relTol * Math.Max(Math.Abs(a), Math.Abs(b)), absTol);
}
Console.WriteLine(FloatBits.AlmostEqual(big1, big2)); // True
Console.WriteLine(FloatBits.AlmostEqual(total, 1.0)); // True
Console.WriteLine(FloatBits.AlmostEqual(1e-12, 0.0)); // False
Console.WriteLine(FloatBits.AlmostEqual(1e-12, 0.0, absTol: 1e-9)); // True
Toleransın değeri alana göre değişir; bir sensör ölçümü ile bir koordinat hesabı aynı toleransı istemez. Testlerde de aynı mantık geçerli: xUnit'in Assert.Equal(expected, actual, tolerance) overload'u mutlak tolerans kullanır, büyük değerlerde bunu göz önünde bulundur.
Floating point toplamanın bir özelliği daha var: işlem sırası sonucu değiştirir. Matematikte (a + b) + c = a + (b + c), ama burada değil:
double[] values = [1e16, 1.0, -1e16, 1.0];
Console.WriteLine(values[0] + values[1] + values[2] + values[3]); // 1
Console.WriteLine(values[0] + values[2] + values[1] + values[3]); // 2
1e16'ya 1 eklendiğinde sonuç değişmiyor (o büyüklükte komşu double'lar arasındaki fark 2), bu yüzden ilk sırada bir tane 1 kayboluyor. Pratik sonucu şu: bir double array'i paralel olarak ya da SIMD (Single Instruction Multiple Data; tek instruction'la birden fazla veriyi aynı anda işleme) ile toplayan bir kod, sıralı bir döngüden farklı bir sonuç üretebilir. İki sonuç da "doğru"; yalnızca toplama sırası farklı. Raporlarda iki sistemin toplamı son basamakta tutmuyorsa sebep çoğu zaman budur.
Half, float, double ve decimal
| Tip | Boyut | Taban | Anlamlı basamak | En büyük değer |
|---|---|---|---|---|
Half | 2 byte | 2 | yaklaşık 3 | 65504 |
float | 4 byte | 2 | yaklaşık 6–9 | yaklaşık 3,4 × 10^38 |
double | 8 byte | 2 | yaklaşık 15–17 | yaklaşık 1,7 × 10^308 |
decimal | 16 byte | 10 | 28–29 | yaklaşık 7,9 × 10^28 |
Half (.NET 5+) makine öğrenmesi ve grafik verisi gibi hassasiyetin az, bellek miktarının önemli olduğu yerler için var; iş mantığında kullanılmaz. decimal ise tamamen farklı bir temsil: 96 bitlik bir integer ve bu integer'ın kaç basamak sağa kayacağını söyleyen bir scale (0–28). Taban 10 olduğu için 0.1 tam olarak 1 × 10^-1 diye saklanır:
int[] parts = decimal.GetBits(0.1m);
Console.WriteLine($"{parts[0]} {parts[1]} {parts[2]} scale={(parts[3] >> 16) & 0xFF}"); // 1 0 0 scale=1
Console.WriteLine(1m / 3m); // 0.3333333333333333333333333333
Console.WriteLine(1m / 3m * 3m); // 0.9999999999999999999999999999
Console.WriteLine(1.0 / 3.0 * 3.0); // 1
decimal da sınırlı: 1/3'ü o da tam yazamıyor ve son satırda double şans eseri yuvarlamayla doğru sonuca dönüyor. decimal'ın üstünlüğü, insanların onluk sistemde yazdığı değerleri (fiyat, vergi oranı) tam tutması. Para için decimal kullanmanın ayrıntıları, yuvarlama ve kuruş dağıtımı Çözüm Desenleri serisinin "Para ve Yuvarlama" yazısında. Bedeli performans: decimal işlemleri donanımda değil yazılımda yapılır.
TypeScript'te ise seçenek yok, her sayı number, yani double. Frontend'de para hesabı gerekiyorsa tutarları kuruş cinsinden integer olarak tut ve yalnızca ekrana yazarken böl, ya da decimal aritmetiği yapan bir kütüphane kullan:
const priceKurus = [1990, 450, 1200];
const totalKurus = priceKurus.reduce((sum, p) => sum + p, 0);
console.log(totalKurus, (totalKurus / 100).toFixed(2)); // 3640 36.40
const view = new DataView(new ArrayBuffer(8));
view.setFloat64(0, 0.1);
console.log(view.getBigUint64(0).toString(16)); // 3fb999999999999a
İkinci kısım JavaScript'in number'ının C#'taki double ile bit bit aynı olduğunu gösteriyor.
Dönüşümler: Truncation, Saturation ve Parse
Floating point'ten integer'a cast kesirli kısmı atar (sıfıra doğru): (int)3.99 3, (int)-3.99 -3. Değer hedef tipe sığmıyorsa ne olacağı ise uzun süre platforma bağlıydı. .NET 9 ile int, uint, long ve ulong'a dönüşümler x86 ve x64'te de saturating oldu: sığmayan değer hedef tipin sınırına, NaN ise 0'a gider.
double[] inputs = [3.99, -3.99, double.NaN, 1e10, -1e10];
foreach (double x in inputs)
{
Console.WriteLine($"{x} -> {(int)x}");
}
// 3.99 -> 3
// -3.99 -> -3
// NaN -> 0
// 10000000000 -> 2147483647
// -10000000000 -> -2147483648
byte, sbyte, short, ushort ve char gibi küçük tipler ise .NET 10'da hâlâ farklı davranıyor: CoreCLR'da yaygın olan yol, değeri önce saturating olarak int'e çevirip sonra üst bitleri atmak (Microsoft bunun garanti edilmiş bir davranış olmadığını belirtiyor). .NET 11 bunu da saturating yaptı. Aynı programı iki runtime'da çalıştırdım:
double[] inputs = [300.0, -42.0, 65578.0, double.NaN];
Console.WriteLine(RuntimeInformation.FrameworkDescription);
foreach (double x in inputs)
{
Console.WriteLine($"{x,8} byte={(byte)x,3} short={(short)x,6} ushort={(ushort)x,5}");
}
.NET 10.0.13
300 byte= 44 short= 300 ushort= 300
-42 byte=214 short= -42 ushort=65494
65578 byte= 42 short= 42 ushort= 42
NaN byte= 0 short= 0 ushort= 0
.NET 11.0.0
300 byte=255 short= 300 ushort= 300
-42 byte= 0 short= -42 ushort= 0
65578 byte=255 short= 32767 ushort=65535
NaN byte= 0 short= 0 ushort= 0
Önce Tahmin Et'teki üçüncü sorunun cevabı: .NET 10'da 44 (300'ün alt 8 biti, 300 − 256), .NET 11'de 255. Microsoft bunu .NET 11'in breaking change listesinde belgeliyor. checked((byte)x) ise iki sürümde de OverflowException fırlatıyor. Sınır dışı bir değerin cast'le "bir şekilde" dönüşmesine güvenen kod, runtime yükseltmesinde sessizce farklı sonuç üretir; aralığı cast'ten önce kontrol et.
Yuvarlama da varsayılan olarak beklediğin gibi değil. Math.Round varsayılan olarak "banker's rounding" (tam ortadaki değeri çift olana yuvarlama) kullanır:
Console.WriteLine(Math.Round(2.5)); // 2
Console.WriteLine(Math.Round(3.5)); // 4
Console.WriteLine(Math.Round(2.5, MidpointRounding.AwayFromZero)); // 3
Console.WriteLine(2.675.ToString("F20")); // 2.67499999999999982236
console.log(Math.round(2.5), Math.round(-2.5), Math.round(3.5)); // 3 -2 4
console.log((2.675).toFixed(2), (1.005).toFixed(2)); // 2.67 1.00
JavaScript'in Math.round'u ortadaki değeri artı sonsuza doğru yuvarlar, bu yüzden -2.5 için -2 verir. (2.675).toFixed(2) ise 2.67 verir, çünkü bellekteki değer 2.675 değil 2.67499999…. Onluk sistemde yazılmış bir değeri belirli basamağa yuvarlamak istiyorsan bu değer double'da durmamalı.
Son olarak parse. double.Parse varsayılan olarak thread'in culture'ını kullanır ve binlik ayırıcıya izin verir:
Console.WriteLine(double.Parse("3,14", CultureInfo.GetCultureInfo("tr-TR"))); // 3.14
Console.WriteLine(double.Parse("3,14", CultureInfo.InvariantCulture)); // 314
Console.WriteLine(double.Parse("3.14", CultureInfo.GetCultureInfo("tr-TR"))); // 314
Console.WriteLine(double.TryParse("3,14", NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)); // False
Türkçe culture'da virgül ondalık ayırıcı, nokta binlik ayırıcı; invariant culture'da tersi. Yanlış culture exception fırlatmaz, sayıyı 100 kat büyütür. Dış sistemden gelen veriyi her zaman CultureInfo.InvariantCulture ve NumberStyles.Float ile parse et; binlik ayırıcı kabul edilmediği için yanlış biçim sessizce kabul edilmek yerine reddedilir. Culture'ın genel davranışı C# Temelleri serisinin "C# String'ler: Interpolation, StringBuilder ve Türkçe I Tuzağı" yazısında anlatıldı.
Üretimde Dikkat
Para, vergi, stok miktarı gibi onluk sistemde tanımlı değerler için decimal; ölçüm, istatistik, koordinat, fizik gibi zaten yaklaşık olan değerler için double. Veritabanında da aynı ayrım geçerli: SQL Server'da decimal(p, s) ve float.
Finansal raporlarda iki sistemin toplamları tutmuyorsa önce toplama sırasına ve tipe bak. Biri double ile paralel toplarken diğeri decimal ile sıralı topluyorsa son basamaklar farklı çıkar. Raporlanan toplamı tek bir yerde, tek bir tiple hesapla.
NaN'ı sistemin sınırında yakala. Sıfıra bölme, Math.Sqrt ya da Math.Log ile oluşan bir NaN hesaplardan geçerek her şeye bulaşır; sonunda JSON'a yazılmaya çalışıldığında System.Text.Json varsayılan olarak exception fırlatır. Hesap sonucunu double.IsFinite ile kontrol edip anlamlı bir hata üret.
Runtime yükseltmelerinde breaking change listesini oku. .NET 9 ve .NET 11'deki dönüşüm değişiklikleri derleme hatası vermez; yalnızca sınır dışı değerlerde farklı sonuç üretir. Bu tür değerlerle test yazmadıysan farkı production'da görürsün.
Sık Düşülen Tuzaklar
== ile double karşılaştırmak. Hesaplanmış iki double'ı karşılaştırırken her zaman toleranslı bir karşılaştırma kullan; toleransı da sayıların büyüklüğüne göre seç.
NaN'ın collection'lardaki davranışını bilmemek. Array.Sort NaN'ı en başa koyar, LINQ'in (Language Integrated Query; C# içinde collection'lar üzerinde sorgu yazmayı sağlayan özellik) Min'i NaN döndürür, Max'i ise görmezden gelir:
double[] prices = [19.9, double.NaN, 4.5, 12.0];
Array.Sort(prices);
Console.WriteLine(string.Join(", ", prices)); // NaN, 4.5, 12, 19.9
Console.WriteLine(prices.Max()); // 19.9
Console.WriteLine(prices.Min()); // NaN
var byPrice = new Dictionary<double, string> { [double.NaN] = "unknown" };
Console.WriteLine(byPrice.ContainsKey(double.NaN)); // True
Dictionary ve Contains NaN'ı bulur çünkü Equals kullanır; == ile yazılmış kendi arama kodun bulamaz. JavaScript'te de benzer bir ikilik var: [NaN].includes(NaN) true, [NaN].indexOf(NaN) -1. NaN kontrolü için her iki dilde de double.IsNaN / Number.isNaN kullan. TypeScript, NaN === NaN yazdığında error TS2845: This condition will always return 'false'. hatasıyla seni durdurur.
double.Epsilon'ı karşılaştırma toleransı olarak kullanmak. Bu değer 4,9 × 10^-324, yani en küçük subnormal. Math.Abs(a - b) < double.Epsilon neredeyse a == b ile aynı şey.
float'ı "yeterince iyi" sanmak. 24 bitlik hassasiyet yaklaşık 7 basamak; 16.777.216'dan büyük integer'ları bile tam tutamaz. Bellek gerçekten sorun değilse double kullan.
Küçük Bir Deneme
★☆☆ FloatBits.Split'i 0.5, 0.75, 1.5 ve 1e-310 için çalıştır. Hangisinin exponent'i 0 çıkıyor ve neden? Sonucu subnormal tanımıyla açıkla.
★★☆ Bir milyon tane 0.1'i double ile sıralı topla, sonra aynı değerleri Kahan summation algoritmasıyla (her adımda kaybolan küçük farkı ayrı bir değişkende biriktirip sonraki adıma ekleyen toplama yöntemi) topla. İki sonucu ve decimal ile bulunan 100000'i karşılaştır.
★★★ Bookstore'un indirim hesabını double kullanan bir versiyonla decimal kullanan bir versiyon olarak yaz. 10.000 rastgele sepet için iki sonucun kuruş düzeyinde kaç kez farklı çıktığını say ve farklı çıkan bir sepeti bit düzeyinde açıkla.
Bu yazıyla sayıların temsilini bitirdik. Serinin sonraki yazısı text'e geçiyor ve Unicode metninin UTF (Unicode Transformation Format; Unicode karakterlerini byte'lara çeviren encoding ailesi) ailesinden UTF-8 ve UTF-16 ile nasıl saklandığını anlatıyor: bir karakterin neden bazen 1, bazen 2, bazen 4 byte tuttuğunu ve bir emojinin string.Length'te neden 2, hatta 8 göründüğünü göreceğiz.