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

Higher-Order Function ve Composition: map/filter/reduce, Currying ve Pipeline Kurmak

Mert Özen 15 Kas 2026 12 dk 8 görüntülenme
Higher-Order Function ve Composition: map/filter/reduce, Currying ve Pipeline Kurmak

Higher-order function fonksiyon alan ya da döndüren fonksiyondur; composition ise bunları birbirine bağlayıp yeni fonksiyon üretmektir ve sırası bir iş kuralıdır. Bookstore'un kupon ve üye indirimini Func<decimal, decimal> kuralları olarak Then, Compose ve Pipe ile birleştiriyor, sıranın sonucu 1,50 TL değiştirdiğini test ediyor; fold'u, currying'i, TypeScript pipe'ını ve closure allocation maliyetini 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 RxJS 7.8.2 ile derlenip denenmiştir.

Önce Tahmin Et: İki İndirimin Sırası

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) indirim kuralları artık birer fonksiyon. PercentOff(0.10m) tutarın yüzde 10'unu düşen, AmountOff(15m) tutardan 15 TL düşen bir Func<decimal, decimal> döndürüyor; Then ise iki fonksiyonu art arda bağlıyor. Pazarlama ekibi "kuponu ve üye indirimini birlikte uygula" diyor, ama hangisinin önce uygulanacağını söylemiyor. 249,40 TL'lik sepet için iki satır aynı sonucu mu verir?

Func<decimal, decimal> couponThenMember = PriceRules.PercentOff(0.10m).Then(PriceRules.AmountOff(15m)).Then(PriceRules.RoundToKurus);
Func<decimal, decimal> memberThenCoupon = PriceRules.AmountOff(15m).Then(PriceRules.PercentOff(0.10m)).Then(PriceRules.RoundToKurus);
Console.WriteLine($"coupon then member: {couponThenMember(249.40m)}");
Console.WriteLine($"member then coupon: {memberThenCoupon(249.40m)}");

Cevap: Composition Sırası Bir İş Kuralıdır

$ dotnet run --project src/Bookstore.Fp.Demo -- 04-compose
coupon then member: 209.46
member then coupon: 210.96

04-rule-order-tr.png

Fark 1,50 TL: 15 TL'nin yüzde 10'u. Kupon önce uygulanırsa yüzde 10, 249,40 üzerinden hesaplanıyor; üye indirimi önce uygulanırsa 234,40 üzerinden. İki yüzde kuralı kendi aralarında yer değiştirebilir, çünkü çarpma sırası sonucu değiştirmez; sabit tutarlı bir kural karışınca değiştiremez. Matematikte bunun adı: fonksiyon composition'ı genel olarak değişme özelliği taşımaz.

İlk yazıdaki LegacyCheckout'ta bu sıra kodun satır sırasına gömülüydü; birisi if bloklarının yerini değiştirse kimse fark etmezdi. Kurallar fonksiyon olunca sıra görünür bir değer oluyor: bir listede duruyor, test edilebiliyor, iş birimine "hangisi önce?" diye sorulabiliyor. Bu yazı bunun araçlarını kuruyor: higher-order function, fold, composition, currying.

Higher-Order Function: Fonksiyon Alan, Fonksiyon Döndüren

Bir fonksiyonu parametre olarak alan ya da dönüş değeri olarak döndüren fonksiyona higher-order function denir. PriceRules'ın method'ları ikinci türden:

public static class PriceRules
{
    public static Func<decimal, decimal> PercentOff(decimal rate) => amount => amount * (1 - rate);

    public static Func<decimal, decimal> AmountOff(decimal discount) => amount => Math.Max(0m, amount - discount);

    public static Func<decimal, decimal> AddVat(decimal rate) => amount => amount * (1 + rate);

    public static readonly Func<decimal, decimal> RoundToKurus =
        amount => Math.Round(amount, 2, MidpointRounding.AwayFromZero);

    public static readonly Func<decimal, decimal> Identity = amount => amount;

    public static Func<decimal, decimal> Combine(IEnumerable<Func<decimal, decimal>> rules) =>
        rules.Aggregate(Identity, (combined, rule) => combined.Then(rule));
}

PercentOff bir oran alıyor ve "bir tutar ver, indirimli tutarı döndüreyim" diyen bir fonksiyon üretiyor. Dönen lambda rate'i closure ile yakalıyor; yakalama mekaniği C# İleri serisinin "C#'ta Lambda ve Closure: Yakalanan Değişken Tuzağı" yazısında. Burada önemli olan, kural ayarının (oran) ve kuralın uygulanmasının (tutar) iki ayrı ana bölünmesi: kuralı uygulama başlarken bir kez kurar, sonra binlerce sepete uygularsın.

Birinci tür, fonksiyon alan higher-order function'ları ise her gün kullanıyorsun. LINQ (Language Integrated Query; C#'ın collection'lar üzerinde sorgu yazma özelliği) ve JavaScript array method'ları aynı fikrin iki ayrı adlandırması:

C# LINQTypeScript / JavaScriptNe yapar
SelectmapHer element'i dönüştürür
WherefilterKoşulu sağlayanları bırakır
AggregatereduceHepsini tek bir değere indirger
SelectManyflatMapHer element'ten bir dizi üretip düzleştirir
Any / Allsome / everyKoşulu en az biri / hepsi sağlıyor mu

LINQ'in map/filter/reduce yerine SQL'den gelen adları (Select, Where) seçmesi bilinçli bir tercihti; sorgu syntax'ı ile method syntax'ının aynı adları kullanmasını sağlıyor. Operatörlerin ayrıntısı LINQ serisinde; bu yazı onların ortak şekline bakıyor.

Fold: Her Döngü Bir Aggregate'tir

Tablodaki satırlardan biri diğerlerinden daha genel: Aggregate, functional programming literatüründeki adıyla fold. Bir başlangıç değeri ve "şu ana kadarki sonuç ile sıradaki element'ten yeni sonucu üret" diyen bir fonksiyon alır. Sepet üzerindeki farklı sorular aynı kalıpla yazılabiliyor:

decimal subtotal = Lines.Aggregate(0m, (sum, line) => sum + line.LineTotal);
decimal maxPrice = Lines.Aggregate(decimal.MinValue, (max, line) => Math.Max(max, line.UnitPrice));
bool anyEbook = Lines.Aggregate(false, (found, line) => found || line.IsEbook);
List<string> titles = Lines.Aggregate(new List<string>(), (list, line) => line.Quantity > 0 ? [.. list, line.Title] : list);

Console.WriteLine($"subtotal: {subtotal}, max: {maxPrice}, ebook: {anyEbook}, titles: {string.Join(", ", titles)}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-fold
subtotal: 249.40, max: 189.50, ebook: True, titles: Dune, Emma

Toplam, en büyük, "herhangi biri" ve hatta filtreleyip dönüştürmek (son satır Where ile Select'in işini birlikte yapıyor) hep fold. Graham Hutton'ın 1999'daki "A Tutorial on the Universality and Expressiveness of Fold" makalesi, liste üzerindeki çok geniş bir fonksiyon ailesinin fold ile yazılabildiğini gösterir. Pratikteki anlamı: state'i bir değişkende tutup döngüde güncellediğin her kod, state'i akümülatöre taşıyan bir fold'a çevrilebilir. Bu, döngüyü her zaman fold'a çevirmen gerektiği anlamına gelmiyor; Sum, Max, Any gibi adı olan operatörler niyeti daha iyi anlatır. Fold'u bilmenin değeri, adı olmayan bir birikim gerektiğinde (sepetteki ISBN'leri (kitapların uluslararası standart numarası), aynı yayınevinden gelenleri gruplayarak toplamak gibi) aracın elinde olması.

Son satırdaki [.. list, line.Title] her adımda yeni bir liste üretiyor; akümülatörü değiştirmek yerine yenisini döndürmek, ikinci yazıdaki immutability fikrinin fold'daki karşılığı. Büyük listelerde bunun maliyetini Tuzaklar bölümünde konuşacağız.

Composition: Then, Compose ve Pipe

Composition, bir fonksiyonun çıktısını diğerinin girdisi yapıp ikisinden yeni bir fonksiyon üretmek. C#'ta bunun için dilde bir operatör yok, ama birkaç satırlık extension method'larla yazılabiliyor:

public static class FuncExtensions
{
    // f.Then(g): önce f, sonra g
    public static Func<TIn, TOut> Then<TIn, TMid, TOut>(this Func<TIn, TMid> first, Func<TMid, TOut> second) =>
        input => second(first(input));

    // g.Compose(f): f'den sonra g, matematikteki sıra
    public static Func<TIn, TOut> Compose<TIn, TMid, TOut>(this Func<TMid, TOut> second, Func<TIn, TMid> first) =>
        input => second(first(input));

    public static TOut Pipe<TIn, TOut>(this TIn value, Func<TIn, TOut> function) => function(value);

    public static Func<T1, Func<T2, TOut>> Curry<T1, T2, TOut>(this Func<T1, T2, TOut> function) =>
        first => second => function(first, second);

    public static Func<T2, TOut> Partial<T1, T2, TOut>(this Func<T1, T2, TOut> function, T1 first) =>
        second => function(first, second);
}

Then ve Compose aynı işi ters yönden okuyor. Matematikteki g ∘ f gösterimi "f'den sonra g" demek ve sağdan sola okunur; Compose bu sırayı izliyor. Then ise kodun okunduğu yönde, soldan sağa. Pipe bir fonksiyon üretmiyor, bir değeri fonksiyondan geçiriyor; F#'taki |> operatörünün karşılığı. Üçü de aynı sonucu veriyor:

Func<decimal, decimal> sameAsFirst = PriceRules.RoundToKurus.Compose(PriceRules.AmountOff(15m)).Compose(PriceRules.PercentOff(0.10m));
Console.WriteLine($"Compose, mathematical order: {sameAsFirst(249.40m)}");

decimal piped = 249.40m.Pipe(PriceRules.PercentOff(0.10m)).Pipe(PriceRules.AmountOff(15m)).Pipe(PriceRules.RoundToKurus);
Console.WriteLine($"Pipe: {piped}");
Compose, mathematical order: 209.46
Pipe: 209.46

Hangisini seçeceğin ekibin okuma alışkanlığına bağlı. C# kodu soldan sağa ve yukarıdan aşağıya okunduğu için çoğu ekipte Then ya da Pipe daha doğal; Compose matematik geçmişi olanlara tanıdık gelir ama sırayı tersten okumayı gerektirir. Önemli olan bir codebase'de tek bir yön seçmek.

Kuralları Liste Olarak Birleştirmek ve Test Etmek

PriceRules.Combine, bir kural listesini tek bir fonksiyona indiriyor. İçinde yine bir fold var: başlangıç değeri hiçbir şey yapmayan Identity fonksiyonu, adım ise "şu ana kadar birleştirilmiş fonksiyonu sıradaki kuralla Then yap". Boş liste Identity'yi döndürür; yani hiç kural yoksa tutar değişmez:

List<Func<decimal, decimal>> rules = [PriceRules.PercentOff(0.10m), PriceRules.AmountOff(15m), PriceRules.RoundToKurus];
Console.WriteLine($"Combine(list): {PriceRules.Combine(rules)(249.40m)}, Combine(empty): {PriceRules.Combine([])(249.40m)}");
Combine(list): 209.46, Combine(empty): 249.40

Artık kural sırası listedeki sıra; bir iş kuralı olarak test edilebiliyor:

[Theory]
[InlineData(249.40, 209.46)]
[InlineData(100.00, 75.00)]
[InlineData(10.00, 0.00)]
public void Coupon_is_applied_before_the_member_discount(decimal subtotal, decimal expected)
{
    Func<decimal, decimal> checkoutRules = PriceRules.Combine(
        [PriceRules.PercentOff(0.10m), PriceRules.AmountOff(15m), PriceRules.RoundToKurus]);

    Assert.Equal(expected, checkoutRules(subtotal));
}

[Fact]
public void Rule_order_changes_the_result()
{
    Func<decimal, decimal> couponFirst = PriceRules.PercentOff(0.10m).Then(PriceRules.AmountOff(15m));
    Func<decimal, decimal> memberFirst = PriceRules.AmountOff(15m).Then(PriceRules.PercentOff(0.10m));

    Assert.NotEqual(couponFirst(249.40m), memberFirst(249.40m));
}

[Theory]
[InlineData(0.00)]
[InlineData(249.40)]
public void Combining_no_rules_returns_the_amount_unchanged(decimal amount) =>
    Assert.Equal(amount, PriceRules.Combine([])(amount));
$ dotnet test
Test run summary: Passed!
  total: 13
  failed: 0
  succeeded: 13
  skipped: 0
  duration: 571ms

Üçüncü satırdaki 10 TL'lik sepet bir edge case'i yakalıyor: yüzde 10'dan sonra 9 TL kalıyor, 15 TL düşülünce AmountOff'un içindeki Math.Max tutarı sıfırda tutuyor. Kural tek başına test edilebildiği için bu sınır davranışı bütün checkout akışını kurmadan doğrulanıyor. Yeni bir kural eklemek de aynı ölçüde kolay: KDV'yi (katma değer vergisi) indirimlerden sonra eklemek listenin sonuna PriceRules.AddVat(0.10m) koymaktan ibaret ve sıranın kendisi bir test satırı.

Currying ve Partial Application

PercentOff'un iki aşamalı imzası (önce oran, sonra tutar) tesadüf değil. İki parametreli bir fonksiyonu, birinci parametreyi alıp ikincisini bekleyen bir fonksiyon döndüren zincire çevirmeye currying denir; adı mantıkçı Haskell Curry'den gelir. Partial application ise parametrelerin bir kısmını şimdi verip kalanı için yeni bir fonksiyon almak. Sonuçları aynı görünse de fark şu: currying fonksiyonun şeklini değiştirir, partial application o şekli kullanarak bir değer sabitler.

Func<decimal, decimal, decimal> applyRate = (rate, amount) => Math.Round(amount * (1 - rate), 2, MidpointRounding.AwayFromZero);

Func<decimal, Func<decimal, decimal>> curried = applyRate.Curry();
Func<decimal, decimal> autumn = curried(0.10m);
Func<decimal, decimal> welcome = applyRate.Partial(0.05m);

decimal[] prices = [189.50m, 59.90m, 149.90m];
Console.WriteLine($"autumn: {string.Join(", ", prices.Select(autumn))}");
Console.WriteLine($"welcome: {string.Join(", ", prices.Select(welcome))}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-curry
autumn: 170.55, 53.91, 134.91
welcome: 180.03, 56.91, 142.41

prices.Select(autumn) satırının çalışmasının sebebi autumn'un tek parametreli olması: Select tam olarak bunu bekliyor. Haskell ve F#'ta her fonksiyon varsayılan olarak curried olduğu için bu iş kendiliğinden olur; C#'ta ise Curry ve Partial gibi yardımcılar ya da doğrudan fonksiyon döndüren factory method'lar (PercentOff gibi) gerekir. Pratikte en okunur olanı son seçenek: adı olan bir factory, anonim bir Curry çağrısından daha çok şey anlatır.

TypeScript'te pipe ve RxJS

TypeScript'te arrow function'lar currying'i doğal hâle getiriyor ve reduce ile bir pipe yardımcısı kolayca yazılıyor. Tip güvenliği için her uzunluk için ayrı bir overload gerekiyor; fp-ts ve Effect gibi kütüphaneler de aynı yolu izler:

export function pipe<A>(value: A): A;
export function pipe<A, B>(value: A, f1: (a: A) => B): B;
export function pipe<A, B, C>(value: A, f1: (a: A) => B, f2: (b: B) => C): C;
export function pipe<A, B, C, D>(value: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D): D;
export function pipe(value: unknown, ...fns: Array<(x: unknown) => unknown>): unknown {
  return fns.reduce((acc, fn) => fn(acc), value);
}

export const compose =
  <A, B, C>(second: (b: B) => C, first: (a: A) => B) =>
  (a: A): C =>
    second(first(a));
import { from, pipe as rxPipe, filter, map, reduce } from "rxjs";
import { sampleCart, type CartLine } from "./cart.ts";
import { compose, pipe } from "./fp.ts";

type PriceRule = (amount: number) => number;

// Curried kural factory'leri: ayarı alır, kuralı döndürür
const percentOff = (rate: number): PriceRule => (amount) => amount * (1 - rate);
const amountOff = (discount: number): PriceRule => (amount) => Math.max(0, amount - discount);
const roundToKurus: PriceRule = (amount) => Math.round(amount * 100) / 100;

const combine = (rules: readonly PriceRule[]): PriceRule => (amount) => rules.reduce((acc, rule) => rule(acc), amount);

console.log("pipe:", pipe(249.4, percentOff(0.1), amountOff(15), roundToKurus));
console.log("member first:", pipe(249.4, amountOff(15), percentOff(0.1), roundToKurus));
console.log("compose:", compose(roundToKurus, compose(amountOff(15), percentOff(0.1)))(249.4));
console.log("combine:", combine([percentOff(0.1), amountOff(15), roundToKurus])(249.4));

// Yeniden kullanılabilir bir RxJS operatörü de bir composition
const discountedSubtotal = rxPipe(
  filter((line: CartLine) => line.quantity > 0),
  map((line) => percentOff(0.1)(line.unitPrice * line.quantity)),
  reduce((sum, amount) => sum + amount, 0),
);
from(sampleCart()).pipe(discountedSubtotal).subscribe((total) => console.log("rxjs:", roundToKurus(total)));
$ node src/04-composition.ts
pipe: 209.46
member first: 210.96
compose: 209.46
combine: 209.46
rxjs: 224.46

RxJS'in bağımsız pipe fonksiyonu (burada rxPipe adıyla import edildi) operatörleri birleştirip yeni bir operatör üretiyor; Observable üzerindeki .pipe(...) ise bir değeri fonksiyonlardan geçiren Pipe'ın karşılığı. Bir RxJS zinciri yazdığında aslında composition yapıyorsun; operatörlerin kendisi ve custom operator yazmak RxJS serisinin "RxJS'te Custom Operator Yazmak ve Okunur Kalmak: pipe Kompozisyonu, Lint Kuralları ve Debug" yazısında.

Sık Düşülen Tuzaklar

Point-free stili okunabilirliğin önüne koymak. Parametre adı hiç yazılmadan, yalnız fonksiyonları birleştirerek yazılan koda point-free denir. prices.Select(autumn) bunun iyi bir örneği; RoundToKurus.Compose(AmountOff(15m)).Compose(PercentOff(0.10m)) ise okuru zinciri sağdan sola çözmeye zorluyor. Üç dört adımı geçen bir composition'da ara sonuçlara ad vermek (discounted, withShipping) ya da düz bir method yazmak, debugger'da breakpoint koyabilmek ve stack trace'te anlamlı bir ad görmek demek. Composition bir araç; her satırı ona çevirmek gerekmiyor.

Hot path'te (en sık çalışan kod yolu) closure allocation'ını unutmak. Bir kuralı her element için yeniden kurmak ile bir kez kurmak arasındaki farkı ölçelim:

long before = GC.GetAllocatedBytesForCurrentThread();
foreach (decimal price in prices)
    total += PriceRules.PercentOff(0.10m)(price);
long perItem = GC.GetAllocatedBytesForCurrentThread() - before;

before = GC.GetAllocatedBytesForCurrentThread();
Func<decimal, decimal> autumn = PriceRules.PercentOff(0.10m);
foreach (decimal price in prices)
    total += autumn(price);
long once = GC.GetAllocatedBytesForCurrentThread() - before;

before = GC.GetAllocatedBytesForCurrentThread();
foreach (decimal price in prices)
    total += price * 0.90m;
long plain = GC.GetAllocatedBytesForCurrentThread() - before;

Console.WriteLine($"rule created per item: {perItem} bytes, created once: {once} bytes, no delegate: {plain} bytes ({total})");
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-alloc
rule created per item: 96000 bytes, created once: 96 bytes, no delegate: 0 bytes (1621350.00)

Her PercentOff çağrısı 96 byte ayırıyor: yakalanan rate'i tutan closure object'i ve delegate. Bin element için 96 KB. .NET 10'un JIT'i (Just-In-Time derleyici; derlenmiş .NET kodunu çalışma anında makine koduna çeviren bileşen), method'un dışına kaçmayan delegate'leri stack'e alabiliyor (escape analysis), ama burada delegate PercentOff'tan döndürüldüğü için kaçıyor ve heap'te kalıyor. Kural: fonksiyonları bir kez kur, çok kez uygula. Escape analysis'in nasıl çalıştığına Compiler'lar ve Programlama Dilleri serisinde bakılacak.

LINQ deferred execution ile çoklu enumeration. Select içindeki fonksiyon, sorgu her enumerate edildiğinde yeniden çalışır:

int priceLookups = 0;
IEnumerable<decimal> discounted = Lines
    .Where(line => line.Quantity > 0)
    .Select(line =>
    {
        priceLookups++;
        return PriceRules.PercentOff(0.10m).Then(PriceRules.RoundToKurus)(line.LineTotal);
    });

decimal sum = discounted.Sum();
decimal max = discounted.Max();
Console.WriteLine($"sum: {sum}, max: {max}, price lookups: {priceLookups}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 04-enum
sum: 224.46, max: 170.55, price lookups: 4

İki satırlık sepet için dört hesaplama. Fonksiyon pure olduğu için sonuç doğru, yalnızca iş iki kez yapılıyor; fonksiyon bir veritabanı ya da fiyat servisi çağırsaydı iki kat istek atılırdı. Sonucu birden fazla kez kullanacaksan ToList() ya da ToArray() ile bir kez somutlaştır. Ayrıntısı LINQ serisinin "LINQ Deferred Execution: Sorgu Ne Zaman Çalışır? IEnumerable ve IQueryable" yazısında.

TypeScript'te parayı number ile hesaplamak. JavaScript'in number'ı ikili kayan noktalı sayı; 249.4 * 0.9 tam olarak 224.46 değil. Bu örnekte Math.round(x * 100) / 100 sonucu düzeltiyor, ama yarım kuruşta ve büyük toplamlarda sürpriz çıkar. Gerçek fiyat hesabında tutarı kuruş cinsinden tam sayı olarak tut ya da hesabı backend'deki decimal'a bırak.

Küçük Bir Deneme

★☆☆ PriceRules.AddVat(0.10m)'u kural listesinin başına ve sonuna ekleyip 249,40 TL için sonuçları karşılaştır. Fark nereden geliyor? (İpucu: 15 TL'lik üye indirimi KDV'den önce mi, sonra mı düşülüyor?)

★☆☆ Lines.Aggregate(...) ile sepetteki basılı kitapların ISBN'lerini virgülle ayrılmış tek bir string'e indir. Aynı işi string.Join ve Where/Select ile yaz; hangisi daha okunur?

★★☆ FuncExtensions'a üç parametreli fonksiyonlar için Curry overload'ı ekle ve (rate, minimum, amount) => ... şeklinde "en az şu kadar indirim" kuralını curry'le.

★★★ PriceRules.Combine'ı, her kuralın adını ve uygulandıktan sonraki tutarı bir List<(string Rule, decimal Amount)> olarak da döndürecek şekilde değiştir; fatura ekranı bu listeyi gösterecek. Bunu fonksiyonları pure tutarak, yani dışarıdaki bir listeye Add yapmadan nasıl yaparsın?

Bir sonraki yazıyla serinin ikinci bloğuna geçiyoruz: veriyi tiplerle modellemek. Sipariş durumunu IsPaid, IsShipped, IsCancelled bayraklarıyla tutan Order class'ının yarattığı imkânsız durumları algebraic data type'larla ortadan kaldıracağız.