Functional Programming'in Sınırları: Performans, Okunabilirlik ve Ekipte Pragmatik Kullanım
Functional programming'in C#'taki bedeli çoğu zaman sanıldığı yerde değil: ölçümde LINQ küçük bir sepette döngüden nanosaniyeler düzeyinde yavaş, Option'ın maliyeti struct ya da class olmasından çok closure'lardan geliyor, beklenen hatayı exception ile taşımak ise Result'tan yüz kat pahalı. BenchmarkDotNet sonuçlarını, composition'da stack trace sorununu, ekipte kazandıran ve tartışmalı fikirleri, F#'ı ve Bookstore'un son hâlini gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.402) ile, ölçümler BenchmarkDotNet 0.15.8 ile, F# örneği .NET 10 SDK'daki F# 10 ve F# Interactive ile derlenip denenmiştir.
Önce Tahmin Et: LINQ mi, Döngü mü?
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) code review'unda iki yorum tartışma çıkardı. Birincisi, sepet ara toplamını hesaplayan satır için: "LINQ (Language Integrated Query; C#'ın sorgu yazma özelliği) yavaş, bunu foreach ile yaz."
[Benchmark(Baseline = true)]
public decimal Loop()
{
decimal subtotal = 0m;
foreach (CartLine line in _lines)
{
if (line.Quantity > 0)
subtotal += line.LineTotal;
}
return subtotal;
}
[Benchmark]
public decimal Linq() => _lines.Where(line => line.Quantity > 0).Sum(line => line.LineTotal);
İkincisi, altıncı yazıdaki kupon hesabı için: "Option her adımda yeni bir değer üretiyor; bu kadar lambda ve Option, düz if'lerden kaç kat pahalı?" Üç sürümü ölçtük: null kontrolleriyle yazılmış klasik kod, struct olan Option<T> ile yazılmış CouponLookup.CouponDiscount ve aynı zincirin class olarak yazılmış bir Option'la hâli. Üç satırlık ve 1000 satırlık bir sepette LINQ döngüden kaç kat yavaş? Kupon hesabının Option'lı hâli kaç byte allocation yapar?
Cevap: Ölçmeden Bilinmiyor
$ dotnet run -c Release --project benchmarks/Bookstore.Fp.Benchmarks -- --filter '*'
| Method | Lines | Mean | Ratio | Allocated |
|------- |------ |-------------:|------:|----------:|
| Loop | 3 | 6.416 ns | 1.00 | - |
| Linq | 3 | 17.624 ns | 2.75 | 48 B |
| Loop | 1000 | 3,003.717 ns | 1.00 | - |
| Linq | 1000 | 4,571.002 ns | 1.52 | 48 B |
| Method | Code | Mean | Ratio | Allocated |
|------------------- |----------- |---------:|------:|----------:|
| NullChecks | autumn10 | 17.26 ns | 1.00 | 80 B |
| StructOption | autumn10 | 24.97 ns | 1.45 | 152 B |
| StructOptionInline | autumn10 | 19.99 ns | 1.16 | 80 B |
| ClassOption | autumn10 | 21.84 ns | 1.27 | 80 B |
Tablolar BenchmarkDotNet'in raporundan; ortam bilgisini ve Error, StdDev, Gen0 sütunlarını yer kazanmak için çıkardık. Ölçümler bir geliştirici makinesinde yapıldı; senin makinende mutlak süreler farklı çıkar, oranlara ve allocation'a bak.
LINQ gerçekten daha yavaş: üç satırlık sepette 2,75 kat, 1000 satırlık sepette 1,52 kat. Ama mutlak farka bak: üç satırlık bir sepette 11 nanosaniye. Bir checkout isteği veritabanına ve ödeme sağlayıcısına gidip milisaniyeler harcıyor; aradaki fark yaklaşık beş basamak. Büyük sepette oranın düşmesinin sebebi, sabit maliyetin (iterator ve delegate çağrıları kurmak, 48 byte'lık allocation) satır sayısına bölünmesi ve decimal toplamasının kendisinin pahalı olması. Code review'daki ilk yorum bu kod için haklı değil; aynı hesap bir raporda milyonlarca satır üzerinde ve saniyede binlerce kez çalışıyorsa ölçüp tekrar konuşmak gerekir.
Option tablosunda ilk tahmine uymayan bir şey var. CouponLookup.CouponDiscount (struct Option) 152 byte allocation yapıp null kontrollü koddan 1,45 kat yavaş çalışırken, aynı zinciri benchmark method'unun içine yazdığımız StructOptionInline 80 byte ile 1,16 katta kalıyor. Class olarak yazılmış Option da 80 byte. 80 byte'ın tamamı Trim() ve ToUpperInvariant()'ın ürettiği string'ler; null kontrollü kod da onları üretiyor. Yani Option'ın kendisi burada ek allocation yapmıyor; fark, zincirin nerede durduğundan geliyor. CouponLookup'taki lambda'lar method parametrelerini (campaigns, now, subtotal) yakalıyor ve bu closure'lar her çağrıda oluşuyor; benchmark method'unun içindeki lambda'lar ise yalnızca this'i ve sabitleri kullanıyor. Class Option'ın her Some'ının heap'e gitmemesi de .NET 10 JIT'inin (kodu çalışma anında makine koduna çeviren derleyici) escape analysis'iyle (bir object'in method dışına kaçmadığını kanıtlayıp onu heap yerine stack'te oluşturması) uyumlu; Microsoft Learn'deki .NET 10 runtime yenilikleri sayfası delegate'ler ve küçük object'ler için bu analizin genişletildiğini anlatıyor. Biz burada hangi allocation'ın neden kaldırıldığını tek tek incelemedik; JIT'in bu tarafı Compiler'lar ve Programlama Dilleri serisinin konusu. Altıncı yazıdaki "struct mı class mı" alıştırmasının cevabı da bu: bu ölçekte belirleyici olan tipin türü değil, closure'lar ve JIT'in kodu inline edip edemediği.
Allocation'ın Kaynakları: Closure, Delegate ve Kopya
Functional stilin maliyeti çoğunlukla dört yerden geliyor ve dördünü de seri boyunca gördük.
Closure ve delegate. Bir lambda dışarıdaki bir değişkeni yakaladığında derleyici o değişkeni taşıyan bir class (closure) ve bir delegate oluşturur. Dördüncü yazıdaki ölçümde (04-alloc), 1000 fiyat için kuralı her elemanda yeniden oluşturmak 96.000 byte, bir kez oluşturup tekrar kullanmak 96 byte allocation yapıyordu. Kural: delegate'i döngünün dışında bir kez kur.
Immutable kopya. İkinci yazıda (02-cost) 10.000 elemanlı bir collection'a tek eleman eklemek ImmutableArray ile 80.032 byte, structural sharing yapan ImmutableList ile 696 byte'tı. Immutability'nin maliyeti veri yapısına bağlı; hangi yapının ne zaman seçileceği C# Koleksiyonlar serisinin "C# Immutable Collection'lar ve Doğru Collection'ı Seçmek" yazısında.
Option ve Result sarmalayıcıları. Yukarıdaki tablo gösterdi ki struct ya da class olması bu ölçekte belirleyici değil. Önceki yazılardaki Result<T> bir class; her adım yeni bir Result üretiyor ve başarısız bir doğrulama 184 byte allocation yapıyor. Ama karşılaştırma doğru yapılmalı: Result'ın alternatifi çoğu zaman hiçbir şey değil, exception.
| Method | Quantity | Mean | Ratio | Allocated |
|-------------- |--------- |------------:|------:|----------:|
| ResultFailure | 0 | 20.26 ns | 1.00 | 184 B |
| ThrowAndCatch | 0 | 2,012.10 ns | 99.30 | 432 B |
Aynı adet kuralını ArgumentOutOfRangeException fırlatıp yakalayarak uygulamak yaklaşık yüz kat yavaş ve iki katından fazla allocation yapıyor. Bu, exception'ları yasaklamak için bir gerekçe değil; beklenmeyen hatalar nadirdir ve stack trace'in değeri bu maliyetten büyüktür. Ama bir formun her gönderiminde beklenen doğrulama hatalarını exception ile taşımak, ölçülebilir bir maliyet. Yedinci yazıdaki "beklenen iş hatası Result, beklenmeyen arıza exception" ayrımının bir de performans tarafı var.
Lazy değerlendirme ve çoklu enumeration. LINQ'in deferred execution'ı bir sorguyu her enumerate edişte yeniden çalıştırıyor; dördüncü yazıdaki örnekte (04-enum) iki satırlık bir sepet için dört fiyat hesabı yapılmıştı. Bunun maliyeti allocation değil, tekrar eden iş.
Derin Composition'da Debugging
Composition'ın okunabilirlik kazancı, bir şey ters gittiğinde stack trace'te bir bedel olarak geri dönüyor. Dördüncü yazıdaki PriceRules.Combine ile kurulmuş bir kural zincirinin ortasına hatalı bir kural koyalım:
// Birleştirilmiş bir pipeline'ın ortasına gizlenmiş hatalı bir kural
Func<decimal, decimal> brokenRule = amount => amount / (amount > 200m ? 0m : 1m);
Func<decimal, decimal> checkout = PriceRules.Combine([PriceRules.PercentOff(0.10m), brokenRule, PriceRules.AmountOff(15m), PriceRules.RoundToKurus]);
try
{
Console.WriteLine(checkout(249.40m));
}
catch (DivideByZeroException ex)
{
// Çıktı her makinede aynı olsun diye dosya yollarını siliyoruz
foreach (string frame in ex.StackTrace!.Split(Environment.NewLine))
Console.WriteLine(Regex.Replace(frame, @" in .*$", ""));
}
$ dotnet run --project src/Bookstore.Fp.Demo -- 09-trace
at System.Decimal.DecCalc.VarDecDiv(DecCalc& d1, DecCalc& d2)
at System.Decimal.op_Division(Decimal d1, Decimal d2)
at Bookstore.Fp.Demo.Post09.<>c.<Trace>b__0_0(Decimal amount)
at Bookstore.Ordering.Functional.FuncExtensions.<>c__DisplayClass0_0`3.<Then>b__0(TIn input)
at Bookstore.Ordering.Functional.FuncExtensions.<>c__DisplayClass0_0`3.<Then>b__0(TIn input)
at Bookstore.Ordering.Functional.FuncExtensions.<>c__DisplayClass0_0`3.<Then>b__0(TIn input)
at Bookstore.Fp.Demo.Post09.Trace()
Hangi kuralın patladığını stack trace söylemiyor. Hatalı lambda <>c.<Trace>b__0_0 adını almış; derleyicinin closure için ürettiği class'lar <>c__DisplayClass0_0 olarak görünüyor ve Then üç kez tekrar ediyor. Imperative bir CalculateTotal'da aynı hata "satır 27" derdi. Production'da log'da bu stack trace'i gören kişi kuralların sırasını bilmeden hatayı bulamaz.
Bunun için functional stilden vazgeçmek gerekmiyor, birkaç alışkanlık yetiyor. Kuralları adı olan method'lar ya da factory'ler olarak yaz: PercentOff gibi bir factory'nin döndürdüğü lambda bile stack trace'te factory'nin adını taşır (ayrı bir denemede DivideBy adlı bir factory için frame Rules.<>c__DisplayClass0_0.<DivideBy>b__0 olarak göründü). Zincirleri kısa tut; on adımlı bir pipeline'ı adlandırılmış ara değerlere bölmek hem debugger'da breakpoint koymayı hem stack trace'i okumayı kolaylaştırıyor. Railway zincirlerinde Tap ile ara değerleri log'a yazmak (shell'de) ve hata mesajına adımın adını koymak (stock.insufficient gibi kodlar) aynı işi görüyor. IDE'lerin lambda içine breakpoint koyabildiğini de unutma; Visual Studio ve Rider satırın ortasındaki lambda'yı ayrıca durdurabiliyor.
Okunabilirlik ve Ekip: Hangi Fikir Her Zaman Kazandırır?
Bu serideki fikirlerin hepsi aynı ağırlıkta değil. Code review'da tartışmasız kazandıranlar ile ekibe ve bağlama göre değişenleri ayırmak, functional programming'i bir ekipte kalıcı kılmanın yolu:
- Her zaman kazandıranlar: immutable DTO (data transfer object; katmanlar arasında veri taşıyan sade tip) ve value object (record,
init,with); kararları veren hesaplamanın pure olması ve saatin, I/O'nun shell'e itilmesi; durumları bayraklar yerine sum type ile modellemek (C# 14'te abstract record, C# 15'teclosedya daunion); beklenen iş hatalarını Result ile döndürmek;Select,Where,Sumgibi okunur LINQ. - Bağlama göre değişenler: her method'u Option ya da Result döndürür yapmak; point-free stil (argümanı hiç anmadan
ThenveComposeile fonksiyon kurmak); currying'i günlük API'lerde kullanmak; async railway zincirleri; özel operator tanımlamak; language-ext gibi geniş bir kütüphaneyi bütün codebase'e yaymak.
İkinci listedekilerin ortak noktası, kodu okuyabilmek için ek bir kavram öğrenmeyi gerektirmeleri. Ekibe yeni katılan biri Select, record ve pattern matching'i zaten biliyor; Bind, Apply, Either<L, R> ve monad transformer'ları ise bilmiyor. language-ext gibi bir kütüphane güçlü, ama onu seçmek kendi kavramlarını ve isimlerini (Either, Fin, Eff gibi) codebase'in diline eklemek demek; o kütüphaneyi bilmeyen bir geliştirici ilk haftasını kodu değil kütüphaneyi öğrenmekle geçirir. Pratik bir ölçü: bir functional yapıyı code review'da iki cümleyle açıklayamıyorsan ekibin geneli için henüz erken.
Okunabilirlik tartışmasında genel bir kural da var: functional stil, "ne" sorusunu öne çıkardığı için iş kuralını okumayı kolaylaştırıyor; "nasıl" ve "ne zaman" sorularını (hangi sırayla, kaç kez çalıştı, nerede allocation yaptı) ise gizleyebiliyor. LINQ'in deferred execution davranışı ve dördüncü yazıdaki çoklu enumeration örneği bunun tipik hâli. Hesaplamanın kendisi önemliyse declarative yaz; zamanlaması ve maliyeti önemliyse bunu kodda görünür bırak.
F# ve Haskell'e Kısa Bir Bakış
Bu serideki fikirler C#'a çoğunlukla F# ve Haskell'den geldi. Aynı Bookstore kurallarının F#'taki hâli, dilin bu fikirleri nasıl varsayılan yaptığını gösteriyor:
// Aynı Bookstore kuralları F#'ta: record'lar, bir discriminated union, >> ve |> ile composition
type CartLine = { Isbn: string; Title: string; UnitPrice: decimal; Quantity: int; IsEbook: bool }
type OrderStatus =
| Pending
| Paid of paymentId: string
| Shipped of paymentId: string * trackingNo: string
| Cancelled of reason: string
let percentOff rate amount = amount * (1m - rate)
let amountOff discount amount = max 0m (amount - discount)
let roundToKurus (amount: decimal) = System.Math.Round(amount, 2, System.MidpointRounding.AwayFromZero)
// Önce kupon, sonra üye indirimi, sonra yuvarlama
let checkoutRules = percentOff 0.10m >> amountOff 15m >> roundToKurus
let cart =
[ { Isbn = "9780441013593"; Title = "Dune"; UnitPrice = 189.50m; Quantity = 1; IsEbook = false }
{ Isbn = "9780141439587"; Title = "Emma"; UnitPrice = 59.90m; Quantity = 1; IsEbook = true }
{ Isbn = "9780261103344"; Title = "The Hobbit"; UnitPrice = 149.90m; Quantity = 0; IsEbook = false } ]
let subtotal =
cart
|> List.filter (fun line -> line.Quantity > 0)
|> List.sumBy (fun line -> line.UnitPrice * decimal line.Quantity)
printfn "subtotal: %M, after rules: %M" subtotal (checkoutRules subtotal)
let describe status =
match status with
| Pending -> "pending"
| Paid paymentId -> $"paid with {paymentId}"
| Shipped (_, trackingNo) -> $"shipped with {trackingNo}"
| Cancelled reason -> $"cancelled: {reason}"
[ Pending; Paid "PAY-1001"; Shipped("PAY-1001", "TRK-778"); Cancelled "customer request" ]
|> List.iter (describe >> printfn "%s")
$ cd fsharp
$ dotnet fsi Discount.fsx
subtotal: 249.40, after rules: 209.46
pending
paid with PAY-1001
shipped with TRK-778
cancelled: customer request
209,46, dördüncü yazıda PriceRules.Combine ile bulduğumuz sonuç. Farklara bak: record'lar varsayılan olarak immutable ve with yerine { line with Quantity = 2 } yazılıyor; her fonksiyon kendiliğinden curried, percentOff 0.10m doğrudan bir partial application; >> bizim Then extension'ımızın, |> ise Pipe'ın dildeki karşılığı. OrderStatus beş satırlık bir discriminated union ve bir kolu unutunca derleyici uyarıyor (bu kısım ayrı bir script'te denendi):
Short.fsx(8,11): warning FS0025: Incomplete pattern matches on this expression. For example, the value 'Cancelled (_)' may indicate a case not covered by the pattern(s).
F#, C# ile aynı runtime'da çalıştığı için bir solution'da yan yana durabiliyor: domain core'u F#'ta, API ve altyapıyı C#'ta yazan ekipler var. Haskell ise bir adım öteye gidiyor: varsayılan olarak bütün fonksiyonlar pure, side effect'ler tipte görünüyor (IO) ve değerlendirme lazy. Haskell kodu bu seride çalıştırılmadı; merak eden için yol haritası şöyle: önce Microsoft Learn'deki F# dokümantasyonu ve Scott Wlaschin'in fsharpforfunandprofit.com sitesi, domain modelleme için Wlaschin'in Domain Modeling Made Functional (2018) kitabı, ardından tip sisteminin pure fonksiyonları nasıl zorunlu kıldığını görmek için Haskell. Category theory gibi matematiksel temeller bu yolun sonunda, başında değil.
Bookstore Kapanışı: LegacyCheckout'tan Functional Core'a
Serinin ilk yazısındaki LegacyCheckout.CalculateTotal, bir sepet toplamı hesaplarken çağıranın listesinden satır siliyor, console'a yazıyor, static bir field'a son toplamı bırakıyor ve sonucu makinenin saatine bağlıyordu. Dokuz yazı sonra aynı iş birkaç küçük parçaya bölünmüş durumda:
| Konu | LegacyCheckout (birinci yazı) | Serinin sonu |
|---|---|---|
| Kod | 39 satırlık tek bir class | CheckoutCalculator (32), CheckoutService (15), PriceRules (20), CouponLookup (31) |
| Gizli girdi | DateTime.Now, sabit kupon kodu ve tarih | saat TimeProvider ile shell'de; kampanya parametre |
| Girdiyi değiştirme | çağıranın listesinden satır siliyor | Cart immutable; boş satırlar filtreleniyor |
| Side effect | Console.WriteLine ve static LastTotal | log yalnızca CheckoutService ve OrderPlacement'ta |
| Sipariş durumu | üç bool ve üç nullable property | OrderStatus sum type; C# 15'te closed |
| "Yok" ve hata | null, 0 ve bool | Option ve Result |
| Test | saat değiştirilmeden test edilemiyor | 39 test; core testleri saat ve fake gerektirmiyor |
$ dotnet test --solution Bookstore.Fp.slnx
Test run summary: Passed!
total: 39
failed: 0
succeeded: 39
skipped: 0
Dürüst olmak gerekirse kod azalmadı, arttı. 39 satırlık method'un yerinde artık yaklaşık 100 satırlık bir fiyatlama çekirdeği var; Option, Result ve sipariş akışıyla birlikte Bookstore.Ordering 490 satır. Bu satırların bir kısmı yeni iş (kupon normalizasyonu, stok ayırma, doğrulama), bir kısmı da C#'ın dilde sunmadığı ve bizim yazdığımız altyapı (Option<T>, Result<T>, Then, Combine). Kazanılan şey satır sayısı değil: her kural ayrı ayrı okunabiliyor ve test edilebiliyor, "1 Aralık'ta ne olur?" sorusu saati ileri almadan bir [Theory] satırıyla cevaplanıyor, sipariş durumu ya da hata kodu eklemek derleyici uyarısı üretiyor. Birinci yazıda LegacyCheckout'u anlamak için gövdenin tamamını okuyup sırayı akılda tutmak gerekiyordu; şimdi CheckoutCalculator.Calculate'in imzası ne yaptığını söylüyor.
Sık Düşülen Tuzaklar
Performansı tahminle tartışmak. Bu yazının ilk iki yorumu, ölçülmeden yapılmış iki varsayımdı. LINQ üç satırlık sepette döngüden 11 nanosaniye yavaş; Option'lı kupon hesabı null kontrollü hâlinden en fazla 8 nanosaniye. Doğrulama hatasını exception ile taşımak ise yüz kat pahalı; yani sezgi bu üç sorunun ikisinde yanlış yere bakıyordu. Bir değişikliği performans gerekçesiyle reddetmeden önce BenchmarkDotNet ile ölç; ölçümü de gerçekten sık çalışan kod üzerinde yap. Ayrıntılı yöntem LINQ serisinin "LINQ Performansı: Çoklu Enumeration, Materialize ve BenchmarkDotNet ile Ölçmek" ve C# İleri serisinin "C#'ta Performans: Allocation, Struct'lar ve BenchmarkDotNet ile Ölçmek" yazılarında.
Hot path'te functional soyutlamayı körü körüne kullanmak. Saniyede yüz binlerce kez çalışan bir parse döngüsünde her adımda bir closure ve bir Option üretmek ölçülebilir bir maliyet. Orada Span<T>, döngü ve erken return doğru araç; dışarıya yine pure bir fonksiyon imzası verebilirsin. Purity bir arayüz özelliği: içeride local bir değişkeni güncelleyen döngü, dışarıdan bakınca pure bir fonksiyonu bozmaz.
Kütüphaneye ekip adına tek başına karar vermek. Bir functional programming kütüphanesini bir PR (pull request; kod değişikliğinin birleştirilmesi için açılan talep) ile codebase'e sokmak, herkesin okuyacağı dili değiştirmek demek. Önce küçük bir modülde dene, ekibin code review'da rahat okuyabildiğini gör, sonra yay.
Functional programming'i bir kimlik meselesi yapmak. Amaç functional kod yazmak değil, daha az şaşırtan ve daha kolay test edilen kod yazmak. Bir class, bir foreach ya da bir if daha açıksa onu yaz.
Küçük Bir Deneme
★☆☆ SubtotalBenchmarks'a Lines = 100_000 ekleyip tekrar çalıştır. LINQ ile döngü arasındaki oran sepet büyüdükçe nasıl değişiyor? Bookstore'da gerçekten 100.000 satırlık bir sepet var mı, yoksa bu ölçüm başka bir ekranın (raporlar) sorusu mu?
★☆☆ Post09.Trace'teki brokenRule lambda'sını private static decimal DivideBySomething(decimal amount) adlı bir method'a çevir ve stack trace'i tekrar oku. Ne değişti?
★★☆ F# script'ine bir Delivered case'i ekle ve describe'ın verdiği uyarıyı oku. Sonra checkoutRules'a KDV (katma değer vergisi) ekleyen bir addVat 0.10m adımı koy; aynı işi dördüncü yazıdaki PriceRules ile C#'ta yap ve iki kodu karşılaştır.
★★★ Ekibin için bir "functional programming code review kontrol listesi" yaz: bu yazıdaki iki listeden hangilerini kural, hangilerini öneri olarak koyarsın? Listeyi bir PR'da gerçek bir değişiklik üzerinde dene.
Seri burada bitiyor. Bu dokuz yazıda Bookstore'un ödeme ve sipariş kodu, gizli girdileri ve bayrakları olan tek bir method'dan küçük, pure ve test edilmiş fonksiyonlara dönüştü; bunu yaparken C# ve TypeScript'in zaten sahip olduğu araçları kullandık ve C# 15'in sum type'larını ekledik. Buradan devam etmek isteyenler için iki doğal yol var: RxJS operatörlerinin composition'ı ilerideki RxJS serisinde, mevcut bir codebase'i adım adım bu yöne taşımanın teknikleri ise ilerideki Refactoring serisinde.