Mert Özen Her Satırda Daha İleri
Bilgisayar Bilimi Temelleri Compiler'lar ve Programlama Dilleri — #08

Programlama Dilleri Nasıl Evrilir: Lisp'ten C#'a Paradigmalar ve Runtime Async'e Uzanan Yol

Mert Özen 15 Kas 2026 11 dk 7 görüntülenme
Programlama Dilleri Nasıl Evrilir: Lisp'ten C#'a Paradigmalar ve Runtime Async'e Uzanan Yol

Dil özellikleri çoğu zaman önce compiler'ın var olan yapılarla taklit ettiği bir hile olarak doğar, yaygınlaşınca runtime onu doğrudan destekler: generic'ler, lambda'lar ve .NET 11'in Runtime Async'i bunun örnekleri. Fortran ve Lisp'ten C#'a kısa bir soy ağacı, paradigmaların C#'ta bir arada kullanımı, static/dynamic ve strong/weak typing, C# ile TypeScript'in nominal ve structural farkı ve Runtime Async ile 13 satırdan 5 satıra inen canlı stack, gerçek çıktılarla.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404) ile derlenip xUnit v3 4.0.3 testleriyle doğrulanmıştır; Runtime Async örneği .NET 11 (SDK 11.0.100) ile, TypeScript örneği TypeScript 7.0.2 ve Node.js 26 ile çalıştırılmıştır.

Önce Tahmin Et

Serinin son yazısında geriye çekilip dillerin nasıl değiştiğine bakıyoruz. Önce iki soru. Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için sipariş akışını üç async method olarak yazıyoruz: PlaceOrderAsync, ReserveStockAsync'i, o da ChargeAsync'i await ediyor. En içteki method, henüz hiçbir await askıya alınmadan çağrı stack'ini yazdırıyor:

await PlaceOrderAsync("9780441172719", 2);

static async Task PlaceOrderAsync(string isbn, int quantity)
{
    await ReserveStockAsync(isbn, quantity);
    await Task.Yield();
}

static async Task ReserveStockAsync(string isbn, int quantity)
{
    await ChargeAsync(quantity * 349.90m);
    await Task.Yield();
}

static async Task ChargeAsync(decimal amount)
{
    // ödeme alınırken, hiçbir await askıya alınmadan önceki canlı çağrı stack'i
    Console.WriteLine(new StackTrace());
    await Task.Yield();
}

Birinci soru: Çağrı zincirinde dört method var (Main dahil). Yazdırılan stack'te kaç satır görürsün? .NET 11'in Runtime Async'i açılınca bu sayı değişir mi?

İkinci soru: TypeScript'te Book bekleyen bir fonksiyona, hiçbir yerde Book olduğu söylenmemiş ama aynı alanlara sahip bir API cevabı verirsen compiler ne der? Aynı şeyi C#'ta iki record ile denersen?

Kısa Bir Soy Ağacı

Bu seride gördüğümüz fikirlerin çoğu 1950'lerin sonuyla 1970'lerin arasında ortaya çıktı. IBM'de (dönemin en büyük bilgisayar üreticisi) John Backus'un ekibinin geliştirdiği Fortran (1957) ilk yaygın yüksek seviyeli dil ve ilk optimize eden compiler'lardan biri oldu. John McCarthy'nin Lisp'i (1958) kodu veri olarak tutmayı ve garbage collection'ı getirdi; 04. yazıdaki expression tree'ler ve 07. yazıdaki garbage collector bu çizginin torunları. ALGOL (ALGOrithmic Language; uluslararası bir komitenin tasarladığı dil) dilinin 1960 tarihli raporu syntax'ı BNF (Backus-Naur Form; grammar'ı kurallarla yazma biçimi) ile tanımladı; 03. yazıda filtre dilinin grammar'ını yazarken kullandığımız EBNF (Extended BNF; BNF'e tekrar ve seçenek gösterimleri ekleyen biçim) onun genişletilmiş hâli. ALGOL'un blok yapısı ve lexical scope'u, Dennis Ritchie'nin Bell Labs'te yazdığı C (1972) üzerinden bugünkü dillerin çoğuna geçti.

Nesne yönelimli programlama (OOP) Simula 67'nin class'larıyla başladı, Alan Kay'in Xerox'un Palo Alto'daki araştırma merkezinde geliştirdiği Smalltalk'la "her şey object ve mesaj" fikrine dönüştü; C++, Java (1995) ve 2002'de 1.0 sürümü çıkan C# bu yoldan geldi. Smalltalk'tan türeyen Self dili de 05. yazıdaki V8 (Chrome ve Node.js'in JavaScript engine'i) gibi runtime'ların hidden class, inline cache ve deoptimization fikirlerinin kaynakları arasında. Fonksiyonel tarafta Robin Milner'ın ML'i (Meta Language; Edinburgh'da bir ispat aracı için geliştirilen dil) tip çıkarımını ve pattern matching'i yaygınlaştırdı, Haskell ise saf fonksiyonları ve lazy evaluation'ı ana akıma taşıdı; Microsoft Research'te Don Syme'ın geliştirdiği F#, ML ailesini .NET'e taşıdı. C#'ın LINQ'i (Language Integrated Query; C#'ın sorgu operatörleri), record'ları ve pattern matching'i bu ailenin etkisini taşıyor.

Paradigmalar Rakip Değil

Paradigma, bir problemi hangi kavramlarla düşündüğün: imperative programlamada adımları ve değişen durumu, OOP'de birbirine mesaj gönderen object'leri, fonksiyonel programlamada değer döndüren ve dışarıyı değiştirmeyen fonksiyonları, declarative programlamada "nasıl" yerine "ne" istediğini yazıyorsun. SQL ve 03. yazıdaki filtre dili declarative; kullanıcı kitapların nasıl taranacağını değil, hangilerini istediğini söylüyor. Modern C# bunları bir arada sunuyor. Aynı soruyu, bir yazarın stoktaki kitaplarının değerini, üç şekilde yazalım:

// imperative: nasıl yapılacağını adım adım, bir değişkeni değiştirerek söyle
public static decimal Imperative(IEnumerable<Book> books, string author)
{
    decimal total = 0;
    foreach (Book book in books)
    {
        if (book.Author == author)
            total += book.Price * book.Stock;
    }
    return total;
}

// declarative ve fonksiyonel: ne istediğini LINQ ve lambda'larla söyle
public static decimal Declarative(IEnumerable<Book> books, string author) =>
    books.Where(b => b.Author == author).Sum(b => b.Price * b.Stock);

// immutable record'lar üzerinde pattern matching
public static decimal Matched(IEnumerable<Book> books, string author) =>
    books.Sum(book => book switch
    {
        { Stock: > 0 } when book.Author == author => book.Price * book.Stock,
        _ => 0m,
    });
$ dotnet run --project samples/Paradigms
2998.00 2998.00 2998.00
System.Collections.Generic.List`1[Bookstore.Catalog.Book]

Üçü de aynı sonucu veriyor ve bir test bunu üç yazar için kontrol ediyor. Hangisini seçeceğin bağlama bağlı: sık çalışan bir döngüde imperative sürüm allocation yapmıyor (05 ve 07. yazılarda gördüğümüz gibi lambda'lar ve enumerator'lar bedava değil), LINQ sürümü niyeti daha açık söylüyor, pattern matching ise şekle göre karar veren kuralları okunur tutuyor. 05. yazıdaki IBookRule interface'i ise aynı problemin OOP yüzü: davranışı bir class hiyerarşisine dağıtıyor.

Tip Sistemleri: Static, Dynamic, Strong, Weak

Tip sistemlerini iki ayrı eksende konuşmak gerekiyor. Birincisi tiplerin ne zaman kontrol edildiği: static typing'de compiler kontrol ediyor (C#, TypeScript, Go), dynamic typing'de değerler çalışma anında kontrol ediliyor (JavaScript, Python). İkincisi dilin uyumsuz tipleri ne kadar kendiliğinden dönüştürdüğü: weak typing'de dil tahmin edip dönüştürüyor, strong typing'de hata veriyor. Bu eksenler bağımsız: Python dynamic ama strong, "3" + 2 hata veriyor; JavaScript dynamic ve weak:

$ node -p '"3" * 2'
6
$ node -p '"349.90" + 25'
349.9025

İkinci satır 01. yazıdaki hatanın ta kendisi: + string'le karşılaşınca birleştirme yapıyor, * ise string'i sayıya çeviriyor. Static typing'in hatayı nerede yakaladığını, C# kodunu Roslyn ile derleyen küçük bir örnekle görelim (04. yazıdaki SemanticModel örneğiyle aynı yöntem):

const string Code = """
    public sealed record Book(string Isbn, string Title, decimal Price);
    public sealed record BookDto(string Isbn, string Title, decimal Price);

    public static class Shipping
    {
        public static decimal WithShipping(Book book) => book.Price + 25m;

        public static decimal FromApi(BookDto dto) => WithShipping(dto);

        public static int Twice() => "3" * 2;
    }
    """;
$ dotnet run --project samples/NominalTyping
(8,64): error CS1503: Argument 1: cannot convert from 'BookDto' to 'Book'
(10,34): error CS0019: Operator '*' cannot be applied to operands of type 'string' and 'int'

CS0019, JavaScript'in sessizce 6 ürettiği ifadeyi derleme anında reddediyor. Tip çıkarımı (type inference) ise static typing'in yazma yükünü azaltıyor: C#'ta var ve lambda parametreleri, TypeScript'te dönüş tipleri compiler tarafından bulunuyor. ML ailesi bunu en uca taşıyor; F#'ta bir fonksiyonun hiçbir tipini yazmadan tamamen static tipli kod yazabiliyorsun.

Nominal ve Structural Typing: C# ve TypeScript

Yukarıdaki CS1503 ikinci tahminin C# yarısı. Book ile BookDto aynı üç property'ye sahip, ama C# nominal bir tip sistemi kullanıyor: bir tipin başka bir tipin yerine geçebilmesi için aralarındaki ilişkinin adıyla (inheritance, interface implementation, tanımlı bir conversion) yazılmış olması gerekiyor. TypeScript ise structural: şekle bakıyor.

interface Book {
  isbn: string;
  title: string;
  price: number;
}

// API'nin döndürdüğü: hiçbir yerde Book olarak tanımlanmadı ama aynı şekle sahip
type BookDto = { isbn: string; title: string; price: number; currency: string };

function withShipping(book: Book): number {
  return book.price + 25;
}

const fromApi: BookDto = { isbn: "9780441172719", title: "Dune", price: 349.9, currency: "TRY" };
console.log(withShipping(fromApi));
$ cd samples/ts-erasure && npx tsc && node dist/structural.js
374.9

tsc hiçbir hata vermedi: BookDto'da Book'un istediği her alan var, fazladan currency alanı da sorun değil. İki yaklaşımın da gerekçesi var. TypeScript, şekli belli ama sınıfı belli olmayan JSON object'leriyle dolu bir dünyaya tip ekledi; structural typing orada doğal. C#'ta ise Book ile BookDto'nun aynı şekilde olması tesadüf olabilir: biri domain modeli, diğeri bir API sözleşmesi ve ileride ayrılacaklar. Nominal typing bu ayrımı açık bir mapping'e zorluyor. Bir de çalışma anı farkı var: 01. yazıda gördüğümüz gibi TypeScript'in tipleri derleme sonrasında tamamen siliniyor, C#'ın tipleri ise metadata'da ve çalışma anında yaşıyor.

Compiler Hilesinden Runtime Özelliğine

Bir dil özelliği iki yoldan gelebilir: compiler onu var olan yapılara çevirir ya da runtime onu doğrudan destekler. Generic'ler iyi bir örnek. Java 5'in generic'leri compiler'da kaldı: tip argümanları derleme sonrasında siliniyor (type erasure) ve çalışma anında bir List<String> ile bir List<Integer> aynı class. .NET 2.0 ise generic'leri runtime'a ekledi; tip argümanı çalışma anında da biliniyor:

// .NET generic'leri çalışma anında da var (reified); TypeScript ve Java onları siliyor
Console.WriteLine(new List<Book>().GetType());

Yukarıdaki Paradigms çıktısının ikinci satırı bu satırdan geliyor: tip, argümanı Book ile birlikte çalışma anında da var. Bu yüzden .NET'te List<int> değerleri boxing olmadan tutuyor ve JIT (just-in-time; çalışma anında makine kodu üreten compiler) her value type için ayrı, özelleşmiş makine kodu üretebiliyor. Lambda'lar ters yönden ilerliyor: C# compiler'ı her lambda'yı bir closure class'ına ve bir delegate'e çeviriyor; runtime yıllarca bunu sıradan iki object olarak gördü. 05. yazıda .NET 10'un JIT'inin dışarı kaçmayan delegate'i stack'e aldığını, closure'ı ise heap'te bıraktığını ölçmüştük: compiler'ın hilesi değişmedi, runtime onu anlamaya başladı.

async/await'in Yolculuğu: State Machine'den Runtime Async'e

async/await C# 5'te (2012) bir compiler özelliği olarak geldi. Compiler her async method'u bir state machine struct'ına çeviriyor; her await bir durum, method askıya alınınca local'ler struct'ın field'larında saklanıyor ve devam ettirme bir callback ile yapılıyor. Mekaniğin ayrıntısı C# Async serisinin "C# async/await Nasıl Çalışır: State Machine ve Continuation" yazısında. Runtime bu modelde async diye bir şey bilmiyor; gördüğü yalnız sıradan method'lar ve struct'lar.

.NET 11, Microsoft Learn'deki yenilikler sayfasına göre Runtime Async'i getiriyor: askıya alma ve devam ettirmeyi compiler'ın ürettiği state machine'ler yerine runtime'ın kendisi yönetiyor. .NET 11'de proje dosyasına <Features>runtime-async=on</Features> ekleyerek açılan bir özellik; artık EnablePreviewFeatures gerektirmiyor ve .NET'in kendi kütüphaneleri bu modelle derleniyor. Örnek projede bunu bir MSBuild property'sine bağladık ve aynı kodu iki şekilde derledik (örnek, .NET 11 SDK'sını seçen kendi global.json'ına sahip):

<PropertyGroup Condition="'$(RuntimeAsync)' == 'true'">
    <Features>runtime-async=on</Features>
</PropertyGroup>
$ cd samples/RuntimeAsync
$ dotnet --version
11.0.100
$ dotnet build -c Release -p:RuntimeAsync=false --no-incremental -v q | grep -E 'succeeded|error'
Build succeeded.
$ dotnet bin/Release/net11.0/RuntimeAsync.dll
   at Program.<<Main>$>g__ChargeAsync|0_2(Decimal amount)
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   at Program.<<Main>$>g__ChargeAsync|0_2(Decimal amount)
   at Program.<<Main>$>g__ReserveStockAsync|0_1(String isbn, Int32 quantity)
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   at Program.<<Main>$>g__ReserveStockAsync|0_1(String isbn, Int32 quantity)
   at Program.<<Main>$>g__PlaceOrderAsync|0_0(String isbn, Int32 quantity)
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   at Program.<<Main>$>g__PlaceOrderAsync|0_0(String isbn, Int32 quantity)
   at Program.<Main>$(String[] args)
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   at Program.<Main>$(String[] args)
   at Program.<Main>(String[] args)
$ ilspycmd -l s bin/Release/net11.0/RuntimeAsync.dll
Struct Program+<<<Main>$>g__ChargeAsync|0_2>d
Struct Program+<<<Main>$>g__PlaceOrderAsync|0_0>d
Struct Program+<<<Main>$>g__ReserveStockAsync|0_1>d
Struct Program+<<Main>$>d__0
$ dotnet build -c Release -p:RuntimeAsync=true --no-incremental -v q | grep -E 'succeeded|error'
Build succeeded.
$ dotnet bin/Release/net11.0/RuntimeAsync.dll
   at Program.<<Main>$>g__ChargeAsync|0_2(Decimal amount)
   at Program.<<Main>$>g__ReserveStockAsync|0_1(String isbn, Int32 quantity)
   at Program.<<Main>$>g__PlaceOrderAsync|0_0(String isbn, Int32 quantity)
   at Program.<Main>$(String[] args)
   at Program.<Main>(String[] args)
$ ilspycmd -l s bin/Release/net11.0/RuntimeAsync.dll

Birinci tahminin cevabı: klasik derlemede 13 satır. Her async method stack'te iki kez görünüyor; arada state machine'i başlatan AsyncMethodBuilderCore.Start var, çünkü dışarıdan çağrılan method yalnız bir başlatıcı, asıl gövde state machine'in MoveNext'inde. ilspycmd her method için bir state machine struct'ı listeliyor. Runtime Async ile stack beş satıra iniyor: yazdığın çağrı zinciri, olduğu gibi. Assembly'de tek bir state machine kalmıyor. Bu fark profiler'ların, debugger'ın call stack penceresinin ve canlı stack okuyan log'ların gördüğü şeyi değiştiriyor. Learn'e göre exception stack trace'leri iki modelde zaten aynı görünüyordu, çünkü klasik model onları ayrıca temizliyor; kazanç canlı stack'te.

Bu, serinin başından beri gördüğümüz bir örüntünün en yeni hâli: bir özellik önce compiler'ın var olan yapılarla taklit ettiği bir hile olarak doğuyor, yaygınlaştıkça runtime onu doğrudan anlamaya başlıyor.

Üretimde Dikkat

Runtime Async'i .NET 11'e geçen bir projede açmadan önce ölç. Learn'deki sayfa, ekibin throughput ve kütüphane boyutu değişiklikleri hakkında geri bildirim beklediğini söylüyor; yani özellik çalışıyor ama her iş yükündeki etkisi henüz yaygın deneyimle oturmuş değil. Async'e yoğun bir servisin ReadyToRun ya da Native AOT (ahead-of-time; makine kodunu publish sırasında üretmek) ile publish edilmesi de destekleniyor; ama 05 ve 06. yazılardaki ölçümler gibi, kendi servisinin sayılarını görmeden karar verme. Profiler ve APM (application performance monitoring; uygulamanın performansını üretimde izleyen araçlar) araçlarının yeni stack şeklini doğru gösterdiğini de kontrol et.

Dil sürümlerini de aynı dikkatle yükselt. Yeni bir C# özelliği genellikle compiler'da yaşıyor ve eski runtime'larda da çalışabiliyor; Runtime Async gibi runtime'a dayanan özellikler ise hedef framework'ü (net11.0) yükseltmeyi gerektiriyor. Bir özelliğin hangi tarafa ait olduğunu bilmek, yükseltme planını da belirliyor.

Sık Düşülen Tuzaklar

Paradigmaları rakip sanmak. "OOP öldü, her şey fonksiyonel olmalı" ya da tersi, aynı codebase'de yan yana yaşayan araçları birbirinin alternatifi gibi gösteriyor. Bookstore'da sipariş durumları bir state machine olarak, katalog sorguları declarative olarak, fiyat hesapları immutable record'lar üzerinde fonksiyonel olarak, ödeme sağlayıcıları ise interface'lerle OOP olarak en iyi ifade ediliyor. Sorulacak soru "hangi paradigma" değil, "bu problem en okunur nasıl yazılır".

Dil karşılaştırmasını benchmark'a indirgemek. "X dili Y'den hızlı" cümlesi çoğu zaman dili değil, implementation'ı ve yazılan kodu ölçüyor. 06. yazıda aynı C# kodu JIT'te Native AOT'den on kat yavaş çıktı; sebep dil değil, her istekte derleme yapan bizim kodumuzdu. 01. yazıdaki ayrım burada da geçerli: hız bir dilin değil, compiler'ın, runtime'ın ve kodun özelliği. Dil seçerken tip sistemi, ekosistem, araçlar ve ekibin deneyimi en az ham hız kadar belirleyici.

Structural typing'e güvenip sözleşmeleri karıştırmak. TypeScript'te bir API DTO'sunu (data transfer object; sistemler arasında veri taşıyan basit tip) domain tipinin yerine sessizce kullanabilmek, iki tip ayrıştığında hatanın çok uzak bir yerde ortaya çıkması demek. Sınırlarda açık mapping ve çalışma anında doğrulama (örneğin JSON şeması) gerekiyor; tipler silindiği için TypeScript bunu senin yerine yapmıyor.

Küçük Bir Deneme

★☆☆ Runtime Async örneğinde ChargeAsync'teki Console.WriteLine(new StackTrace()) satırını await Task.Yield()'in altına taşı ve iki derlemeyi yeniden çalıştır. Stack'te hangi method'lar kaldı, neden?

★★☆ samples/ts-erasure'de withShipping'e fromApi değişkeni yerine object literal'i doğrudan ver. tsc bu sefer currency için ne dedi? Excess property checking'in structural typing ile ilişkisini açıkla.

★★★ Filtre dilini yeni bir paradigmayla genişlet: author in ["Jane Austen", "George Eliot"] gibi bir liste literal'i ekle. Lexer, parser, binder ve code generation'ın her birinde ne değişti? Interpreter'ı, derlenmiş delegate'i ve Entity Framework Core'un ürettiği SQL'i aynı testle karşılaştır.

Seri burada bitiyor. Bookstore'un filtre dilini karakterlerden token'lara, token'lardan ağaca, ağaçtan doğrulanmış bir bound tree'ye ve oradan hem bir delegate'e hem SQL'e taşıdık; sonra aynı yolun .NET'te ara kod, JIT, ahead-of-time derleme ve garbage collection ile nasıl devam ettiğini ölçtük. Bir dil özelliğiyle ya da bir performans sorunuyla karşılaştığında artık şu soruyu sorabilirsin: bu iş pipeline'ın hangi aşamasında, build'de mi yoksa çalışma anında mı yapılıyor? Cevap çoğu zaman ne yapacağını da söylüyor.