Mert Özen Her Satırda Daha İleri
Yazılım Tasarımı Functional Programming Kavramları — #03

Pure Function ve Side Effect: Referential Transparency, Gizli Girdiler ve Test Edilebilirlik

Mert Özen 15 Kas 2026 12 dk 7 görüntülenme
Pure Function ve Side Effect: Referential Transparency, Gizli Girdiler ve Test Edilebilirlik

Pure function aynı girdiye her zaman aynı sonucu veren ve dışarıda iz bırakmayan fonksiyondur; böyle bir çağrı döndürdüğü değerle yer değiştirebilir. Türkçe culture'da 189.50'yi 18950 okuyan decimal.Parse'tan başlayıp Bookstore'un indirim hesabını saat, console ve static state'ten ayırıyor, xUnit Theory ile fake'siz test ediyor, memoization'ı ve Angular computed() ile pure pipe'ın purity varsayımını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (SDK 10.0.402) ile, testler xUnit.net v3 4.0.3 ile, TypeScript örnekleri TypeScript 7.0.2, Node.js 26 ve Angular 22.2 (@angular/core 22.2.6) ile derlenip denenmiştir.

Önce Tahmin Et: "189.50" Kaç Lira?

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı), yayınevlerinden gelen fiyat listelerini CSV (değerleri virgülle ayıran düz metin dosyası) olarak içeri alıyor. Fiyat sütunu nokta ile yazılmış: 189.50. Satırı okuyan kod tek satırlık, masum görünen bir method:

public static class PriceText
{
    public static decimal ParseLegacy(string text) => decimal.Parse(text);

    public static decimal Parse(string text) => decimal.Parse(text, NumberStyles.Number, CultureInfo.InvariantCulture);
}

ParseLegacy dışarıdaki hiçbir şeye dokunmuyor gibi: bir string alıyor, bir decimal döndürüyor. Aynı method, aynı "189.50" girdisiyle, biri Amerika'daki bir CI sunucusunda, diğeri İstanbul'daki bir geliştiricinin makinesinde çalışıyor. İki makinede aynı sonucu mu verir?

foreach (string name in new[] { "en-US", "tr-TR" })
{
    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(name);
    Console.WriteLine($"{name}: ParseLegacy(\"189.50\") = {PriceText.ParseLegacy("189.50").ToString(CultureInfo.InvariantCulture)}, Parse(\"189.50\") = {PriceText.Parse("189.50").ToString(CultureInfo.InvariantCulture)}");
}

Cevap: Görünmeyen Bir Parametre

$ dotnet run --project src/Bookstore.Fp.Demo -- 03-culture
en-US: ParseLegacy("189.50") = 189.50, Parse("189.50") = 189.50
tr-TR: ParseLegacy("189.50") = 18950, Parse("189.50") = 189.50

Türkçe culture'da Dune'un fiyatı yüz kat arttı. decimal.Parse(string) sayıyı thread'in CurrentCulture'ına göre okur. tr-TR'de ondalık ayırıcı virgül, nokta ise binlik ayırıcı; varsayılan NumberStyles.Number binlik ayırıcıya izin verdiği için 189.50 hatasız biçimde 18950 olarak okunuyor. Exception yok, uyarı yok; fiyat listesi yanlış fiyatlarla içeri alınıyor.

ParseLegacy'nin imzasında tek bir parametre var, ama gerçekte iki girdisi var: text ve CultureInfo.CurrentCulture. İkincisi görünmüyor; method'u okurken de çağırırken de fark etmiyorsun. Düzeltilmiş Parse aynı işi yapıyor ama culture'ı açıkça veriyor. Artık sonucu yalnız parametresi belirliyor. Bu yazı bu farkın adını koyuyor: pure function. .NET'in kendi kod analizinde de bu tuzağın bir kuralı var: CA1305, IFormatProvider alan bir overload varken onu kullanmayan çağrıları işaretliyor.

Pure Function ve Referential Transparency

Bir fonksiyon iki koşulu sağlıyorsa pure'dur: aynı girdiye her zaman aynı çıktıyı verir ve çıktısını döndürmek dışında dışarıdan gözlemlenebilen hiçbir şey yapmaz. İlk koşul gizli girdi olmamasını, ikincisi side effect (yan etki; fonksiyonun dönüş değeri dışında dünyada bıraktığı iz) olmamasını söylüyor.

Bu iki koşulun pratik bir sonucu var: referential transparency. Pure bir fonksiyon çağrısını, programın davranışını değiştirmeden döndürdüğü değerle yer değiştirebilirsin. PriceText.Parse("189.50") yazılan her yere 189.50m yazabilirsin; program aynı çalışır. ParseLegacy("189.50") için bunu yapamazsın, çünkü değer culture'a bağlı. İlk yazıdaki LegacyCheckout.CalculateTotal(cart, "AUTUMN10", true) için de yapamazsın: çağrıyı 259.36 ile değiştirirsen console'a yazı düşmez, static field güncellenmez, sepetten satır silinmez ve 1 Aralık'tan sonra sonuç zaten başka.

Referential transparency kodu okuyan insan için de önemli. Pure bir fonksiyonu anlamak için yalnız gövdesine ve parametrelerine bakman yeterli; çağrıldığı yeri, çağrılma sırasını, o anda hangi thread'de olduğunu bilmen gerekmez. İki pure fonksiyonun sırasını değiştirebilir, sonucu cache'leyebilir, paralel çalıştırabilir ya da hiç çağırmayıp sonucunu önceden hesaplayabilirsin. Derleyiciler ve framework'ler de bu varsayımı kullanır; Angular'ın computed()'i bunun bir örneği.

Side Effect Türleri ve Gizli Girdiler

LegacyCheckout'u bu gözle tekrar okuyalım:

03-hidden-io-tr.png

Side effect'ler birkaç başlık altında toplanır:

  • I/O: dosya, veritabanı, ağ, console, log. Bir şey okuyan I/O gizli girdi, bir şey yazan I/O gizli çıktıdır.
  • Mutation: parametre olarak gelen object'i, bir static field'ı ya da paylaşılan bir collection'ı değiştirmek (lines.RemoveAt, LastTotal = total).
  • Exception: bazı girdiler için değer döndürmek yerine akışı kesmek. Çağıran, imzaya bakarak bunu bilemez; bu konuya Result type'ı anlatacağımız yazıda döneceğiz.
  • Zaman ve rastgelelik: DateTime.Now, DateTime.UtcNow, Stopwatch, Random.Shared, Guid.NewGuid(). Her çağrıda başka sonuç verirler; bunlara bağlı bir fonksiyon da öyle.
  • Ortam: CultureInfo.CurrentCulture, sunucunun saat dilimi (DateTime.Now yalnız saati değil, makinenin time zone ayarını da okur), environment variable'lar, static configuration.

Bunların hepsi kötü değil; bir uygulamanın tek amacı side effect üretmek: siparişi kaydetmek, e-posta göndermek, sayfayı çizmek. Amaç onları yok etmek değil, kenara itmek. Kararları veren hesaplama pure kalır, side effect'ler o hesaplamayı çağıran ince bir katmanda toplanır. İlk yazıda adını koyduğumuz functional core, imperative shell düzeni bu.

Bookstore'da Refactor: İndirimi Pure Hâle Getirmek

LegacyCheckout'un kampanya kontrolü DateTime.Now < new DateTime(2026, 12, 1) idi: hem saat hem makinenin saat dilimi gizli girdi. Önce kampanyayı veri hâline getiriyoruz. Başlangıç ve bitiş DateTimeOffset olarak tutuluyor; karşılaştırma UTC (Coordinated Universal Time; saat dilimlerinin referans aldığı evrensel saat) anına göre yapıldığı için sunucunun saat dilimi artık önemli değil:

public sealed record Campaign(string Code, decimal Rate, DateTimeOffset StartsAt, DateTimeOffset EndsAt)
{
    public bool IsActiveAt(DateTimeOffset now) => now >= StartsAt && now < EndsAt;
}

Sonra hesaplamanın kendisi. Saat okumuyor, console'a yazmıyor, static state tutmuyor; şu anki zamanı da bir parametre olarak alıyor:

public sealed record CheckoutTotals(decimal Subtotal, decimal CouponDiscount, decimal MemberDiscount, decimal Shipping)
{
    public decimal Total => Subtotal - CouponDiscount - MemberDiscount + Shipping;
}

// Functional core: saat yok, console yok, static state yok
public static class CheckoutCalculator
{
    public const decimal MemberDiscountAmount = 15m;
    public const decimal FreeShippingThreshold = 300m;
    public const decimal StandardShippingFee = 49.90m;

    public static CheckoutTotals Calculate(Cart cart, bool isMember, Campaign campaign, DateTimeOffset now)
    {
        IEnumerable<CartLine> lines = cart.Lines.Where(line => line.Quantity > 0);
        decimal subtotal = lines.Sum(line => line.LineTotal);
        decimal coupon = CouponDiscount(subtotal, cart.CouponCode, campaign, now);
        decimal member = isMember ? MemberDiscountAmount : 0m;
        bool hasPrintedBooks = lines.Any(line => !line.IsEbook);
        decimal shipping = hasPrintedBooks && subtotal - coupon - member < FreeShippingThreshold ? StandardShippingFee : 0m;
        return new CheckoutTotals(subtotal, coupon, member, shipping);
    }

    public static decimal CouponDiscount(decimal subtotal, string? couponCode, Campaign campaign, DateTimeOffset now) =>
        couponCode == campaign.Code && campaign.IsActiveAt(now)
            ? Math.Round(subtotal * campaign.Rate, 2, MidpointRounding.AwayFromZero)
            : 0m;
}

Calculate'in içinde local değişkenler var ve hiçbiri sonradan değişmiyor; method'un dışından bakınca yalnızca girdiler ve bir CheckoutTotals var. Sonuç tek bir sayı yerine bir record: indirimlerin ve kargonun ayrı ayrı görünmesi hem test hem fatura için daha kullanışlı. Saat, log ve console ise shell'de:

// Imperative shell: saati okur, core'u çağırır, log'a yazar
public sealed class CheckoutService(TimeProvider clock, Campaign campaign, TextWriter log)
{
    public CheckoutTotals Checkout(Cart cart, bool isMember)
    {
        DateTimeOffset now = clock.GetUtcNow();
        CheckoutTotals totals = CheckoutCalculator.Calculate(cart, isMember, campaign, now);
        log.WriteLine($"[checkout] {now:yyyy-MM-dd HH:mm}Z total={totals.Total}");
        return totals;
    }
}

TimeProvider, .NET 8 ile gelen saat soyutlaması; production'da TimeProvider.System verilir, testte sahtesi. Core'u kampanyanın son dakikasında ve ertesi gün, shell'i de gerçek saatle çağıralım:

DateTimeOffset lastMinute = new(2026, 11, 30, 23, 59, 0, TimeSpan.FromHours(3));
DateTimeOffset nextDay = new(2026, 12, 1, 0, 1, 0, TimeSpan.FromHours(3));
Console.WriteLine($"core, last minute: {CheckoutCalculator.Calculate(cart, isMember: true, Autumn, lastMinute)}");
Console.WriteLine($"core, next day:    {CheckoutCalculator.Calculate(cart, isMember: true, Autumn, nextDay).Total}");

CheckoutService service = new(TimeProvider.System, Autumn, Console.Out);
CheckoutTotals totals = service.Checkout(cart, isMember: true);
Console.WriteLine($"shell total: {totals.Total}, cart lines: {cart.Lines.Count}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 03-core
core, last minute: CheckoutTotals { Subtotal = 249.40, CouponDiscount = 24.94, MemberDiscount = 15, Shipping = 49.90, Total = 259.36 }
core, next day:    284.30
[checkout] 2026-11-15 01:23Z total=259.36
shell total: 259.36, cart lines: 3

Core, 1 Aralık'ı beklemeden 1 Aralık'taki davranışını gösteriyor: kupon düşüyor, toplam 284,30 oluyor. Legacy kodda bunu görmek için makinenin saatini ileri almak gerekirdi. Shell'in log satırındaki zaman, bu yazının kodunun çalıştırıldığı an; senin çalıştırdığında başka olacak, ve olması gereken de bu. Sepet hâlâ üç satırlı: Cart immutable ve hesaplama boş satırları silmek yerine filtreliyor.

xUnit ile Test: Saat Yok, Fake Yok

Pure bir fonksiyonun testi, girdi ve beklenen çıktıdan oluşan bir tablodur. xUnit'in [Theory]'si tam bunun için. Kampanya penceresinin dört kritik anı tek bir test method'una sığıyor:

public static TheoryData<string, DateTimeOffset, decimal> CouponCases => new()
{
    { "day before start", new DateTimeOffset(2026, 10, 31, 23, 59, 0, Istanbul), 0m },
    { "first minute", new DateTimeOffset(2026, 11, 1, 0, 0, 0, Istanbul), 24.94m },
    { "last minute", new DateTimeOffset(2026, 11, 30, 23, 59, 0, Istanbul), 24.94m },
    { "midnight Istanbul as UTC", new DateTimeOffset(2026, 11, 30, 21, 0, 0, TimeSpan.Zero), 0m }
};

[Theory]
[MemberData(nameof(CouponCases))]
public void Coupon_applies_only_inside_the_campaign_window(string moment, DateTimeOffset now, decimal expected)
{
    decimal discount = CheckoutCalculator.CouponDiscount(249.40m, "AUTUMN10", Autumn, now);

    Assert.True(expected == discount, $"{moment}: expected {expected}, got {discount}");
}

[Theory]
[InlineData(true, 259.36)]
[InlineData(false, 274.36)]
public void Total_matches_the_legacy_checkout(bool isMember, decimal expected)
{
    CheckoutTotals totals = CheckoutCalculator.Calculate(DuneAndEmma, isMember, Autumn, new DateTimeOffset(2026, 11, 15, 12, 0, 0, Istanbul));

    Assert.Equal(expected, totals.Total);
}
$ dotnet test
Test run summary: Passed!
  total: 7
  failed: 0
  succeeded: 7
  skipped: 0
  duration: 410ms

Son satır özellikle değerli: UTC 21:00, İstanbul'da gece yarısı. Kampanya biter, kupon uygulanmaz. Legacy kodda bu edge case'i (sınırdaki özel durum) test etmek için sunucunun saat dilimini değiştirmen gerekirdi. Testlerde mock yok, fake yok, setup yok; çünkü core'un dünyayla hiçbir bağı yok. xUnit v3, .NET 10 SDK ile Microsoft Testing Platform üzerinden çalışıyor; bunun için global.json'da "test": { "runner": "Microsoft.Testing.Platform" } ayarı var.

Shell'i test etmek istersen Microsoft.Extensions.TimeProvider.Testing paketindeki FakeTimeProvider'ı verirsin. Ama shell'de karar olmadığı için test edilecek çok az şey kalıyor; kararları core'a çekmenin asıl kazancı bu. Constructor injection, seam'ler ve shell'i test etme stratejisi Unit Test serisinin "Test Edilebilir Tasarım: Dependency Injection, Seam'ler, Functional Core ve Humble Object" yazısında ayrıntılı.

Memoization: Pure Function'ı Güvenle Cache'lemek

Referential transparency'nin bir sonucu: pure bir fonksiyonun sonucunu bir kez hesaplayıp saklayabilir, aynı girdi tekrar geldiğinde saklanan değeri döndürebilirsin. Buna memoization denir. Genel bir yardımcı birkaç satır:

public static class Memo
{
    public static Func<TIn, TOut> Memoize<TIn, TOut>(this Func<TIn, TOut> function) where TIn : notnull
    {
        ConcurrentDictionary<TIn, TOut> cache = new();
        return input => cache.GetOrAdd(input, function);
    }
}

ISBN (kitaplara verilen uluslararası standart numara; burada 13 haneli hâli) checksum kontrolü pure; memoize etmek güvenli. Kuponun aktif olup olmadığı ise saate bağlı; aynı yardımcıyla memoize edersek ne olur?

int calls = 0;
Func<string, bool> isValidIsbn = isbn =>
{
    calls++;
    int sum = isbn.Select((c, i) => (c - '0') * (i % 2 == 0 ? 1 : 3)).Sum();
    return isbn.Length == 13 && sum % 10 == 0;
};
Func<string, bool> cachedIsbn = isValidIsbn.Memoize();
bool[] results = [cachedIsbn("9780441013593"), cachedIsbn("9780441013593"), cachedIsbn("9780441013594")];
Console.WriteLine($"pure: {string.Join(", ", results)} with {calls} calls");

DateTimeOffset clock = new(2026, 11, 30, 23, 59, 0, TimeSpan.FromHours(3));
Func<string, bool> isCouponActive = code => code == Autumn.Code && Autumn.IsActiveAt(clock);
Func<string, bool> cachedCoupon = isCouponActive.Memoize();
Console.WriteLine($"23:59 -> cached: {cachedCoupon("AUTUMN10")}, direct: {isCouponActive("AUTUMN10")}");
clock = clock.AddMinutes(2);
Console.WriteLine($"00:01 -> cached: {cachedCoupon("AUTUMN10")}, direct: {isCouponActive("AUTUMN10")}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 03-memo
pure: True, True, False with 2 calls
23:59 -> cached: True, direct: True
00:01 -> cached: True, direct: False

Pure fonksiyon üç çağrıda iki kez çalıştı ve sonuçlar doğru. isCouponActive ise lambda'nın closure ile yakaladığı clock değişkenini okuyor; bu gizli bir girdi. Cache yalnız code'u key olarak kullandığı için gece yarısından sonra da True döndürüyor: kampanya bittiği hâlde indirim uygulanmaya devam ediyor. Cache'in doğru çalışması için key'in fonksiyonun bütün girdilerini kapsaması gerekir; gizli girdi varsa bu imkânsız. Gerçek bir sistemde cache'in süresi ve boyutu da sınırlanmalı (bu yardımcı sonsuza kadar büyür); ASP.NET Core tarafında bunun için HybridCache ve IMemoryCache var.

Angular'da Purity Varsayımı: computed() ve Pure Pipe

Angular'ın computed()'i memoization'ın framework'e gömülü hâli: hesabı bir kez yapar, okuduğu signal'lerden biri değişene kadar saklanan değeri döndürür. Angular hangi signal'lerin okunduğunu izleyebilir; sıradan bir değişkeni izleyemez. Kampanya kontrolünü iki şekilde yazalım:

import { computed, signal } from "@angular/core";

const campaignEnd = new Date("2026-12-01T00:00:00+03:00");

// Pure: ihtiyacı olan her şey parametreyle geliyor
function isCouponActive(code: string, now: Date): boolean {
  return code === "AUTUMN10" && now < campaignEnd;
}

// Impure computed: Angular'ın izleyemediği sıradan bir değişkeni okuyor
let now = new Date("2026-11-30T23:59:00+03:00");
const coupon = signal("AUTUMN10");
const activeImpure = computed(() => isCouponActive(coupon(), now));

// Pure computed: saat de bir signal
const clock = signal(new Date("2026-11-30T23:59:00+03:00"));
const activePure = computed(() => isCouponActive(coupon(), clock()));

console.log("23:59 ->", "impure:", activeImpure(), "pure:", activePure());

now = new Date("2026-12-01T00:01:00+03:00");
clock.set(new Date("2026-12-01T00:01:00+03:00"));
console.log("00:01 ->", "impure:", activeImpure(), "pure:", activePure());
$ node src/03-purity.ts
23:59 -> impure: true pure: true
00:01 -> impure: true pure: false

C#'taki memoize örneğinin aynısı: activeImpure gizli girdisi değiştiğinde haberdar olmuyor ve bayat değeri döndürüyor. computed()'in içinde Date.now(), Math.random(), localStorage ya da bir service'in signal olmayan field'ını okumak hep bu bug'ı üretir. Çözüm, değişebilen her girdiyi signal yapmak ya da fonksiyona parametre olarak vermek.

Pipe'larda da aynı varsayım var. Angular dokümantasyonuna göre pipe'lar varsayılan olarak pure'dur: Angular bir pure pipe'ı yalnızca girdisi değiştiğinde yeniden çalıştırır; primitive değerde değerin, object ve array'de referansın değişmesi gerekir. Pipe'ın içinde saate, culture'a ya da bir service'in değişen state'ine bakarsan, ya da ikinci yazıdaki gibi array'i yerinde değiştirirsen, ekranda eski sonuç kalır. pure: false bir çözüm gibi görünür, ama o zaman pipe her change detection turunda çalışır; maliyet bu kez performansa çıkar.

Sık Düşülen Tuzaklar

Logging'i "side effect değil" saymak. Log bir I/O'dur: dosyaya, console'a ya da ağ üzerinden bir servise yazar ve yazamadığında exception fırlatabilir. Core'un içine ILogger sokunca fonksiyon hem test için bir fake ister hem de memoize edildiğinde log'lar beklenmedik biçimde kaybolur. Log'u shell'de, core'un döndürdüğü sonuçla yaz; hesaplamanın ara adımlarını görmek istiyorsan core sonucun içinde döndürsün (CheckoutTotals'ın indirimleri ayrı ayrı taşıması gibi).

Pure görünen ama mutable argümanı değiştiren method. List<CartLine> alıp lines.Sort(...) çağıran bir "en pahalı kitabı bul" method'u, çağıranın listesinin sırasını değiştirir. İmzası pure görünür, dönüş değeri doğrudur, bug başka bir ekranda ortaya çıkar. Sıralama gerekiyorsa OrderBy ya da kopya üzerinde çalışan bir method kullan; parametreyi IReadOnlyList<T> ya da immutable bir collection olarak almak niyeti de belgeler.

Her şeyi pure yapmaya çalışıp parametre listesini şişirmek. Saat, culture, kampanya, kur, vergi oranları, üyelik, ülke... hepsi parametre olunca method on parametreli bir hâle gelir. Bu, core'a giden girdilerin bir kavram oluşturduğunun işareti: onları bir record'da topla (PricingContext gibi) ve shell o record'u bir kez kursun. Ayrıca her şeyin pure olması gerekmez; pure kalması gereken, kararları veren ve test edilmesi değerli olan hesaplama.

"Şimdi"yi core'un derinliklerinde okumak. Core'un en alt katmanındaki küçük bir yardımcıya DateTime.UtcNow yazmak bütün zinciri impure yapar. TimeProvider bile core'un içine girerse yalnızca test edilebilirlik kazanırsın, purity kazanmazsın. Zamanı shell'de bir kez oku ve değer olarak aşağı geçir.

Küçük Bir Deneme

★☆☆ PriceText.ParseLegacy'yi de-DE ve fr-FR culture'larıyla da çalıştır. Hangi culture'da "189.50" exception fırlatıyor, hangisinde yanlış değer veriyor?

★☆☆ CouponCases tablosuna "kampanya başlamadan bir saniye önce" satırını ekle. Sonra küçük harfle yazılmış "autumn10" kuponu için ayrı bir test yaz: indirim uygulanıyor mu? Bu bir bug mı, yoksa iş kuralı mı?

★★☆ CheckoutService'i FakeTimeProvider ile test et: saati kampanyanın son dakikasına kur, Checkout'u çağır, saati iki dakika ilerlet, tekrar çağır. Bu test, core'un [Theory]'sine göre neyi fazladan doğruluyor?

★★★ Memo.Memoize'a TimeSpan cinsinden bir yaşam süresi parametresi ekle ve süresi dolan kayıtları yeniden hesapla. Yaşam süresini ölçmek için hangi saati kullanacaksın? Bu karar yardımcıyı pure olmaktan çıkarıyor mu?

Bir sonraki yazıda bu pure fonksiyonları birbirine bağlıyoruz: higher-order function ve composition. Kupon, üye indirimi ve KDV (katma değer vergisi) kurallarını Func<decimal, decimal> listesi olarak birleştirecek, kural sırasının sonucu nasıl değiştirdiğini test edeceğiz.