Rill: Çöp Toplayıcısız, Go Tarzı Eşzamanlı Fonksiyonel Bir Dil (Temellerden Bir Web Sunucusuna)
Rill'i baştan sona gezen uzun bir tur: cebirsel veri tipleri, Result ve ?, kuyruk özyineleme, strand ve kanallar, select ile zaman aşımı, çöp toplayıcı olmadan bellek yönetimi ve bu blogun Rill ile yazılmış sunucusu. Tüm kod örnekleri çalıştırılmış, çıktılar gerçek.

Birkaç yıldır kafamda dönen bir soru vardı: Haskell ve OCaml'ın tip sistemini, Go'nun eşzamanlılık modelini ve C'nin çıktısına yakın bir hızı aynı dilde toplamak mümkün mü? Üstelik ortada ne bir sanal makine ne de bir çöp toplayıcı olsun. Rill bu sorunun cevabını aramak için yazdığım dil. Şu an okuduğunuz sayfa da Rill ile yazılmış bir HTTP sunucusundan geliyor.
Bu yazı uzun bir tur olacak. Dilin temellerinden başlayıp cebirsel veri tiplerine, hataların ? ile taşınmasına, döngülerin aslında kuyruk özyineleme olmasına, strand ve kanallarla eşzamanlılığa, çöp toplayıcı olmadan belleğin nasıl yönetildiğine ve en sonunda bu blogun sunucusuna kadar gideceğiz. Yazıdaki bütün kod örneklerini yazmadan önce derleyip çalıştırdım. Çıktılar gerçek çıktılardır.
Rill nedir, ne değildir?
Rill katı (strict), statik tipli ve fonksiyonel bir dil. LLVM üzerinden doğrudan yerel koda derleniyor. Birkaç temel karar dilin karakterini belirliyor:
- Tip çıkarımı her yerde. Tip notları isteğe bağlı. Jenerikler, trait'ler ve "bu tip sıralanabilir olmalı" gibi gereksinimler derleyici tarafından çıkarılıyor ve denetleniyor.
- Cebirsel veri tipleri ve örüntü eşleme. İç içe desenler, koruma ifadeleri (guard) ve eksik bir durumu adıyla söyleyen bir kapsayıcılık denetleyicisi var.
- Çöp toplayıcı yok. Bellek, derleme zamanında yerleştirilen referans sayımıyla (Perceus tarzı) yönetiliyor. Bir değer öldüğü anda serbest bırakılıyor, duraklama yok.
- Go'nun eşzamanlılık modeli.
spawnve tipli kanallar, yeşil iş parçacıkları üzerinde çalışıyor. Rill bunlara strand diyor (Go'daki goroutine'in karşılığı). - LLVM -O3 ile yerel kod. Jenerikler monomorfize ediliyor; hiçbir şey kutulanmıyor ve trait çağrıları derleme zamanında çözülüyor. "Hello world" 33 KB.
- Tek bir kanonik yazım.
rill fmther programa tam olarak tek bir yazım veriyor.
Rill'de olmayan şeyler de bilinçli seçimler: değiştirilebilir değişken yok, istisna (exception) yok, null yok, örtük tip dönüşümü yok, kalıtım yok. Değişebilen tek şey depolama alanları: bir tampon (Buf) elemanı ve bir Map girdisi. Bunlarda bile değişen şey bağlama değil, adın gösterdiği depolama.
İlk adım: kurulum ve "merhaba"
Derleyici Rust ile yazılmış ve LLVM 21'e ihtiyaç duyuyor:
brew install llvm@21
cargo build --release
./target/release/rill run examples/hello.rill
./target/release/rill build main.rill -o main && ./main
Araç zinciri tek bir ikili dosyanın içinde: build, run, check, test, fmt, repl, doc, explain ve bir dil sunucusu (rill lsp). Editörde imlecin altındaki adın tipini görmek, her şeyi çıkaran bir dilde bir imzanın yüksek sesle söylendiği tek yer.
Cebirsel veri tipleri ve örüntü eşleme
Rill'de bir veri tipi, kurucularının listesidir. Aşağıdaki örnekte üç farklı şekil tanımlıyoruz ve her birinin alanını hesaplıyoruz. Üçgen için Heron formülünü kullanıyoruz; karekök için C'nin matematik kütüphanesini doğrudan çağırıyoruz:
type Shape
Circle(r: Float)
Rect(w: Float, h: Float)
Triangle(a: Float, b: Float, c: Float)
fn area(s) = match s
Circle(r) -> 3.14159 * r * r
Rect(w, h) -> w * h
Triangle(a, b, c) ->
p = (a + b + c) / 2.0
sqrt(p * (p - a) * (p - b) * (p - c))
fn describe(s) = match s
Circle(r) if r > 10.0 -> "büyük bir daire"
Circle(_) -> "bir daire"
Rect(w, h) if w == h -> "bir kare"
Rect(_, _) -> "bir dikdörtgen"
Triangle(_, _, _) -> "bir üçgen"
extern "m" fn sqrt(x: Float) -> Float
fn main() =
shapes = Cons(Circle(1.0), Cons(Rect(2.0, 2.0), Cons(Triangle(3.0, 4.0, 5.0), Cons(Circle(12.0), Nil))))
each(shapes, \s -> println(describe(s) + ": " + float_to_str(area(s))))
println(fold(map(shapes, area), 0.0, \acc, a -> acc + a))
Çıktı:
bir daire: 3.14159
bir kare: 4
bir üçgen: 6
büyük bir daire: 452.38895999999994
465.53054999999995
Burada dikkat edilmesi gereken birkaç şey var:
- Hiçbir yerde tip yazmadık (kurucu alanları hariç).
area'nınShape -> Floatolduğunu derleyici kendisi buluyor. describeiçindekiifkorumaları hiçbir şeyi kapsamaz. YaniCircle(r) if r > 10.0satırı tek başına daireleri kapsamış sayılmaz; altındakiCircle(_)satırını silerseniz derleyici eksik olanCircle(_)durumunu adıyla söyler. Korumalı bir kolun bir durumu "saklaması" mümkün değil.extern "m" fn sqrt(...)satırı C'ninlibmkütüphanesindeki fonksiyonu doğrudan bağlıyor. Arada bir sarmalayıcı katman yok.- Ondalıklı sayılar en kısa geri okunabilir biçimde yazdırılıyor:
452.38895999999994, hesaplanan sayının ta kendisi.
Tek kurucusu olan bir tipin alanlarına nokta ile erişebilirsiniz (p.x). Birden fazla kurucusu olan bir tipe ise yalnızca match cevap verebilir, çünkü alan orada olmayabilir. Bir kaydı değiştirmek yerine yenisini kurarsınız: { p | health = p.health - 35 }, "p'nin her şeyi, bunlar hariç" diye okunur.
Hatalar birer değerdir: Result ve ?
Rill'de istisna yok. Başarısız olabilecek bir fonksiyon bunu tipinde söyler ve çağıran unutamaz. Bunun eskiden bir bedeli vardı: her çağrı için bir match ve üç satırından ikisi her seferinde aynı olan bir kalıp. ? operatörü o iki satırın kendisi: Ok içindeki değer, ya da fonksiyonun tamamının Err'i.
Basit bir yapılandırma dosyası okuyucusu yazalım:
type Config
Config(host: Str, port: Int)
fn field(text, key) =
found = find(lines(text), \l -> starts_with(l, key + "="))
match found
Some(l) -> Ok(str_sub(l, str_len(key) + 1, str_len(l)))
None -> Err("eksik alan: " + key)
fn number(s) =
n = str_to_int(s)
if n > 0 && n < 65536 then Ok(n) else Err("geçersiz port: " + s)
fn parse_config(text) =
host = field(text, "host")?
port = number(field(text, "port")?)?
Ok(Config(host, port))
fn main() =
println(parse_config("host=127.0.0.1\nport=8080"))
println(parse_config("host=127.0.0.1"))
println(parse_config("host=x\nport=99999"))
Ok(Config(127.0.0.1, 8080))
Err(eksik alan: port)
Err(geçersiz port: 99999)
? çalışma zamanında hiçbir şey eklemiyor. Derleyici bloğun geri kalanını Ok kolu yapıyor ve elle yazacağınız match'i üretiyor. ? sonrasındaki bir kuyruk çağrısı hâlâ kuyruk çağrısı. Katmanların kendi hata türleri olduğunda, fonksiyonun hata tipi bir toplam tip (type AppErr ile Io(IoErr) ve Parse(ParseErr) gibi) olarak tanımlanırsa, ? alt katmanın hatasını ilgili kurucuyla sarmalayarak dışarı taşır. Tip bir dönüşüm bildirmediyse hiçbir şey sessizce dönüştürülmez.
Döngü yok, ama döngü var
Rill'de klasik anlamda bir while döngüsü yok. Onun yerine kuyruk özyineleme var ve bu bir ipucu değil, bir söz: Rill fonksiyonları LLVM'in garanti edilmiş kuyruk çağrısı kuralıyla derleniyor ve çağrı musttail olarak işaretleniyor. Yani optimize edicinin geri alamayacağı bir garanti. Bir döngü, birbirini kuyruk konumunda çağıran birkaç fonksiyon olarak yazılsa bile sabit yığıtta çalışır.
fn sum_to(n) =
fn go(i, acc) = if i > n then acc else go(i + 1, acc + i)
go(1, 0)
fn main() =
println(sum_to(100000000))
squares = buf_int(10)
for i in 0..10
squares[i] = i * i
total = for i in 0..10 with acc = 0
acc + squares[i]
println(total)
println(range(1, 20) |> filter(\x -> x % 3 == 0) |> map(\x -> x * x) |> sum)
words = split("rill ile yaz rill ile derle rill ile çalıştır", " ")
counts = map_new()
each(words, \w -> map_set(counts, w, map_or(counts, w, 0) + 1))
println(sort_by(map_items(counts), fst))
5000000050000000
285
819
Cons((derle, 1), Cons((ile, 3), Cons((rill, 3), Cons((yaz, 1), Cons((çalıştır, 1), Nil)))))
Bu küçük programda dört farklı fikir var:
- Yerel fonksiyonlar döngüdür, kapanış (closure) değil.
go, çevreleyen fonksiyondakin'yi görüyor. Derleyici onu üst seviyeye kaldırıpn'yi parametre olarak geçiriyor. Hiçbir şey tahsis edilmiyor ve 100 milyon adım sabit yığıtta dönüyor. for ... withtaşıdığı değeri döndürür. Gövdenin değeri bir sonrakiaccoluyor. Gövdeyeacc = acc + v[i]yazmak reddediliyor ve hata mesajı sizewithkullanmanızı söylüyor; çünkü aksi hâlde döngü her yinelemeyi çöpe atardı.- Boru hattı (pipeline) tek bir döngüye dönüşür.
range |> filter |> map |> sumzinciri, elemanlar sayı olduğunda ve fonksiyonlar lambda olarak yazıldığında içinde hiç liste olmayan tek bir döngü olarak üretiliyor. Map(k, v)bir hash tablosu. Yapısal eşitliği olan her şey anahtar olabilir; derleyici hash fonksiyonunu tipten kendisi yazıyor.m[k]birOptiondöndürüyor çünkü anahtar orada olmayabilir. Tablonun sırası tablonun kendisine ait; sıra gerekiyorsasort_byile açıkça istemek gerekiyor.
Eşzamanlılık: strand'ler ve kanallar
Rill, Go'nun modelini kullanıyor: buluşmalı (rendezvous) kanallar üzerinden konuşan çok sayıda hafif iş parçacığı. Strand'ler gerçekten ucuz: 100.000 strand yaklaşık 53 MB tutuyor ve mikrosaniyeler içinde başlıyor. Bunun sırrı "bloklanınca kopyala" (copy-on-block) yığıt modelinde: park edilmiş bir strand yaklaşık 300 bayt.

Önce bir işçi havuzu yazalım. Dört işçi strand'i bir iş kanalını dinliyor, her biri aldığı n için Fibonacci sayısını hesaplayıp sonucu bir demet (tuple) olarak geri yolluyor:
fn fib(n) = if n < 2 then n else fib(n - 1) + fib(n - 2)
fn worker(id, jobs, results) =
n = recv(jobs)
send(results, (id, n, fib(n)))
worker(id, jobs, results)
fn collect(results, k, acc) =
if k == 0 then acc else collect(results, k - 1, Cons(recv(results), acc))
fn main() =
jobs = channel(16)
results = channel(16)
for id in 0..4
spawn worker(id, jobs, results)
for n in 25..33
send(jobs, n)
got = sort_by(collect(results, 8, Nil), job_of)
each(got, report)
fn job_of(t) =
(_, n, _) = t
n
fn report(t) =
(id, n, f) = t
println("fib(" + int_to_str(n) + ") = " + int_to_str(f) + " [işçi " + int_to_str(id) + "]")
fib(25) = 75025 [işçi 0]
fib(26) = 121393 [işçi 0]
fib(27) = 196418 [işçi 0]
fib(28) = 317811 [işçi 0]
fib(29) = 514229 [işçi 0]
fib(30) = 832040 [işçi 0]
fib(31) = 1346269 [işçi 1]
fib(32) = 2178309 [işçi 1]
İşlerin çoğunu 0 numaralı işçinin yapması bir hata değil. Varsayılan derlemede tek bir işçi iş parçacığı var ve strand'ler onun üzerinde sırayla çalışıyor. worker fonksiyonunun kendini sonsuza kadar çağırması da sorun değil; bu bir kuyruk çağrısı, yığıt büyümüyor.
Gerçek paralellik için programı --parallel ile derleyip RILL_THREADS ile işçi sayısını vermek yeterli. Aynı havuza 8 eşit iş (8 kez fib(35)) verip M4 üzerinde 11 çalıştırmanın medyanını aldım. Süreye sürecin başlatılması da dahil:
| Derleme | Süre (medyan) |
|---|---|
rill build (tek işçi) | 131 ms |
rill build --parallel, RILL_THREADS=8 | 53 ms |
Kod tek satır değişmeden yaklaşık 2,5 kat hızlandı. Burada bir uyarı var: --parallel derlemede strand'lerin paylaştığı depolama alanları (bir Map, bir Buf) üzerinde kilit yok. Derleyici, kasıtlı olarak paylaşılan değerleri apart(x) ile işaretlemenizi istiyor; bu sizin "bunu ayrı ayrı yazıyorum" sözünüz.
Zaman aşımı: select ve timeout
Bir servisi beklerken sonsuza kadar beklemek istemezsiniz. select birden fazla kanalı aynı anda bekler ve hangisi önce hazır olursa o kolu çalıştırır. timeout(ms) kolu ise belli bir süre hiçbir şey olmazsa hazır hâle gelir:
fn slow(ch, ms, v) =
sleep_ms(ms)
send(ch, v)
fn ask(ch, ms) =
r = select
v = recv(ch) -> "yanıt geldi: " + v
timeout(ms) -> "zaman aşımı (" + int_to_str(ms) + " ms)"
r
fn main() =
fast = channel()
spawn slow(fast, 10, "hızlı servis")
println(ask(fast, 100))
lazy = channel()
spawn slow(lazy, 500, "yavaş servis")
println(ask(lazy, 100))
yanıt geldi: hızlı servis
zaman aşımı (100 ms)
Burada önemli bir mühendislik detayı var: timeout kolu bir strand ya da kanal harcamıyor. Kanalların yanında, poller'ın içinde park ediliyor ve beklerken hiçbir maliyeti yok. Eskiden zaman aşımı gereken her select kendi saati olarak bir strand ve bir kanal harcıyordu. Uçuştaki her isteğe bir süre sınırı koyan bir sunucu için bu, istek başına bir strand demekti.
Programı durdurmak için de aynı mekanizma kullanılıyor: close(ch) kanalda bekleyen herkesi tek bir yayınla uyandırıyor, wait_signal() programın durması gerektiğini duymasını sağlıyor. Tüm strand'ler bloklandıysa çalışma zamanı kilitlenmeyi (deadlock) raporlayıp programı durduruyor; sessizce asılı kalmıyor.
100.000 strand'lik bir halka
Eşzamanlılık modelinin klasik stres testi: 100.000 strand'i birbirine kanallarla bağlayıp bir halka kuruyoruz. Her strand aldığı sayıyı bir artırıp bir sonrakine iletiyor:
fn relay(inp, out) =
v = recv(inp)
send(out, v + 1)
fn build(first, prev, k) =
if k == 0 then prev
else
next = channel()
spawn relay(prev, next)
build(first, next, k - 1)
fn main() =
t0 = now_nanos()
first = channel()
last = build(first, first, 100000)
send(first, 0)
println(recv(last))
println(int_to_str((now_nanos() - t0) / 1000000) + " ms")
100000
10 ms
100.000 strand'in oluşturulması, 100.000 kanal ve mesajın halkayı baştan sona dolaşması benim makinemde 8 ile 17 ms arasında sürdü ve sürecin tepe belleği 68 MB oldu. Rill deposundaki karşılaştırmalı ölçümler (Apple M4, 25 çalıştırmanın medyanı, üç dilin çıktısının aynı olduğu doğrulanarak) aşağıda:

Hesaplama tarafındaki hız LLVM'den geliyor. Bellek tahsisi sonucunu (binary trees) havuzlu bir ayırıcıyla referans sayımı ve hiç tahsis yapmayan parametresiz kurucular belirliyor. Eşzamanlılık sonucunu ise bloklanınca kopyalanan yığıt modeli belirliyor. Rill üç dil arasında tepe bellek ve ikili dosya boyutunda da en küçüğü: 32–34 KB'a karşılık Go için 2,1 MB, OCaml için yaklaşık 1 MB.
Çöp toplayıcı olmadan bellek
Rill'in en sevdiğim kısmı bu. Derleyici, sahiplik kurallarına göre referans sayım işlemlerini kendisi yerleştiriyor (Perceus tarzı). Bellek son referans öldüğü anda serbest kalıyor; deterministik ve duraklamasız. Nil ve None gibi parametresiz kurucular statik tekil nesneler ve asla bellek ayırmıyor.
Asıl güzel olan kısım yeniden kullanım. Bir match bir kutuyu söküp parçalarına ayırdığında ve o kutuya adıyla erişen başka kimse kalmadığında, aynı şekildeki bir sonraki kurucu yeni bir kutuda değil, o kutunun içinde kuruluyor. Bu yüzden map, filter, reverse ve split bir listeyi hiçbir şey tahsis etmeden yürüyor; her halkayı okuduğu halkanın üzerine yazıyor.

Bunlar kaynak koddan görünmeyen kararlar ama tahmin etmek zorunda değilsiniz. rill explain, kod üreticinin kendi raporu:
type Point
P(x: Float, y: Float)
fn total(xs, acc) = match xs
Nil -> acc
Cons(x, rest) -> total(rest, acc + x)
fn main() =
xs = Cons(1, Cons(2, Cons(3, Nil)))
doubled = map(xs, \x -> x * 2)
println(total(doubled, 0))
p = P(1.0, 2.0)
println(p.x + p.y)
println(range(1, 1000) |> filter(\x -> x % 7 == 0) |> map(\x -> x * x) |> sum)
$ rill explain explain.rill
fn total(xs, acc) # line 4
`xs` is borrowed: never kept or given away here, so the caller's reference is enough
line 6: `rest` is passed to the tail call borrowed: the callee never keeps it
fn main() # line 8
line 9: `Cons(..)` allocates a box with 2 field(s)
line 10: `map` walks the list in place, writing each link over the one it read
line 12: `P(..)` is a flat value: in registers, no box and no count
line 14: this pipeline is one loop: no list is built
Rapor satır satır okunabiliyor:
totallisteyi ödünç alıyor: hiçbir şeyi saklamadığı için çağıranın referansı yetiyor, sayaç hiç artırılmıyor.maplisteyi yerinde yürüyor;doublediçin yeni hücre ayrılmıyor.P(1.0, 2.0)düz bir değer: yalnızca sayı içeren tek kurucu bir kayıt hiç kutulanmıyor ve yazmaçlarda taşınıyor.(q, r) = divmod(a, b)C'de iki sayı döndürmenin maliyetine eşit.- Boru hattı tek bir döngü; arada liste kurulmuyor.
Referans sayımının bilinen tek zayıflığı halkalar: kendisini tutan bir değeri serbest bırakamaz. Değiştirilemez değerlerden oluşan bir programda halka kurmanın tek yolu bir depolamanın onu tutan şeyi tutması. Rill, m[k] = v ifadesinde v eğer m'yi tutuyorsa bunu yazıldığı yerde reddediyor. İki haritanın birbirini tutması gibi derleme zamanında görülemeyen durumlar için de RILL_ALLOC_CHECK=1 ile çalıştırınca serbest kalmayan blokları hangi fonksiyonun yaptığını söyleyen bir rapor var.
Sunucu yazmaya yetecek kadar G/Ç
Bir strand bir soketi beklerken park ediliyor ve zamanlayıcı işletim sistemini yokluyor. Böylece bağlantı başına bir strand, beklediği sürece hiçbir şeye mal olmuyor. TCP'nin üzerindeki her şey sıradan Rill kodu. web/http modülü yaklaşık birkaç yüz satırlık bir HTTP/1.1 sunucusu: istek ayrıştırma, yönlendirme, yanıtlar ve keep-alive. İşte tam bir web sunucusu:
import "web/http" as h
fn route(req: h.Request) =
p = h.path_of(req.path)
if p == "/" then h.html_response(200, "<h1>Merhaba Rill!</h1>")
else if p == "/selam" then h.text_response(200, "selam " + h.param(h.query_of(req.path), "ad"))
else h.json_response(404, "{\"hata\":\"bulunamadı\"}")
fn main() = h.serve_n(18777, \req -> route(req), 10)
$ curl localhost:18777/
<h1>Merhaba Rill!</h1>
$ curl "localhost:18777/selam?ad=Bar%C4%B1%C5%9F"
selam Barış
$ curl localhost:18777/x
{"hata":"bulunamadı"}
Derlenmiş ikili dosya 119 KB. serve_n testler için belli sayıda isteğe cevap verip duruyor; gerçek bir sunucu serve ya da serve_taking kullanıyor. Kütüphane ayrıca saldırılara karşı da düşünülmüş: hiçbir şey göndermeyen bir bağlantı 15 saniye sonra bırakılıyor, istek başlığı 64 KB ve gövde 1 MB ile sınırlı, hem Content-Length hem Transfer-Encoding taşıyan bir istek (request smuggling) hiç cevaplanmadan reddediliyor.
Rill deposundaki ölçümde aynı tek rotalı servis, Go'nun net/http'si ile karşılaştırılmış:

Keep-alive ile Rill hem verimde hem gecikmede önde ve yaklaşık 9 kat daha az bellek kullanıyor. p99 gecikmesinin 3,2 kat daha sıkı olmasının nedeni basit: toplama için hiçbir şey duraklamıyor.
Bu blog nasıl çalışıyor?
Okuduğunuz sayfa yukarıdaki parçaların hepsini bir araya getiriyor. Sunucu Rill ile yazılmış tek bir ikili dosya (yaklaşık 500 KB). Arkasında OxiDB var; doküman, SQL, bellek içi anahtar-değer ve S3 motorlarını tek bir süreçte sunan, yine kendi yazdığım bir veritabanı. Arayüz ise zap, sanal DOM'suz ince taneli reaktif kütüphanem.
| Katman | Teknoloji | Ne yapıyor |
|---|---|---|
| HTTP | Rill web/http | Bağlantı başına bir strand, keep-alive |
| Şablonlar | tools/tmplgen | Şablonlar derleme zamanında Rill koduna çevriliyor |
| Markdown | md.rill | Bu yazının HTML'i, kaydedilirken bir kez üretiliyor |
| SQL | OxiDB SQL | Yazılar ve etiketler; transaction ve yabancı anahtar |
| NoSQL | OxiDB doküman | Türkçe kök bulmalı tam metin arama ve okunma analitiği |
| In-memory | OxiMem (RESP) | Oturum, sayaç, sayfa önbelleği, hız sınırlama |
| Nesne deposu | OxiDB S3 | Bu yazıdaki görseller, SigV4 imzalı isteklerle |
Şablonların derleme zamanında Rill'e çevrilmesi özellikle hoşuma gidiyor. Bir şablon aslında bir fonksiyon. Statik metin dizgi sabitlerine, etiketler de karşılık geldikleri koda dönüşüyor. Sayfa render etmek bir StrBuf'a art arda eklemelerden ibaret; çalışma zamanında hiçbir şey ayrıştırılmıyor ve hiçbir ad aranmıyor:
%% import "views"
%= t_card(c: Card)
<article class="card">
<p class="meta"><time datetime="<%= c.iso %>"><%= c.date %></time> · <%= c.reading %> dk okuma</p>
<h2><a href="/yazi/<%= c.slug %>"><%= c.title %></a></h2>
<% if len(c.tags) > 0 %><p class="tags"><% each t in c.tags %><a href="/etiket/<%= t %>">#<%= t %></a><% end %></p><% end %>
</article>
<%= ... %> içindeki her şey düz Rill kodu ve otomatik olarak HTML'e kaçışlanıyor. Öğrenilecek ikinci bir ifade dili yok.
Bir yazı okunduğunda olanlar da Rill'in eşzamanlılık modelini güzel gösteriyor. Sayfa önce OxiMem'deki önbellekten aranıyor. Okunma sayacı OxiMem'de anında artırılıyor ve sayfaya yerleştiriliyor. Analitik için events koleksiyonuna yazılacak doküman ise ayrı bir strand'e veriliyor (spawn), böylece sayfa veritabanının fsync'ini beklemiyor. Veritabanı bağlantıları bir kanalın içindeki bir havuz: bir istek bir bağlantı alıyor, kullanıyor ve geri koyuyor. Hiç bağlantı bulamayan istek bekliyor. Bu da bir sunucunun zaten istediği geri basıncı (backpressure) bedavaya veriyor:
fn make_pool(host, port, n) =
ch = channel(n)
fn fill(k) =
if k <= 0 then Ok(ch)
else
match connect_to(host, port)
Err(e) -> Err(e)
Ok(fd) ->
send(ch, fd)
fill(k - 1)
fill(n)
fn with_conn(pool, ask) =
fd = recv(pool)
res = ask(fd)
send(pool, fd)
res
Bir yazıyı kaydetmek SQL motorunda tek bir transaction. Önce satır, sonra etiketleri değiştiriliyor. Başka bir yazının kullandığı bir bağlantı adı (slug) UNIQUE kısıtı tarafından reddediliyor ve bu ret doğrudan kullanıcıya gösterilen cevap oluyor:
fn save_post(fd, d: Draft) =
now = now_iso()
ignore(exec(fd, "BEGIN", Nil))
saved = if d.id > 0 then update_post(fd, d, now) else insert_post(fd, d, now)
match saved
Err(e) ->
ignore(exec(fd, "ROLLBACK", Nil))
Err(e)
Ok(id) -> match replace_tags(fd, id, d.tags)
Err(e) ->
ignore(exec(fd, "ROLLBACK", Nil))
Err(e)
Ok(_) -> match exec(fd, "COMMIT", Nil)
Err(e) -> Err(e)
Ok(_) ->
reindex(fd, id)
Ok(id)
Test etmek
Bir test, test_ ile başlayan ve hiçbir şey almayan bir fonksiyon. rill test dosyayı bir kez derliyor ve her testi kendi sürecinde çalıştırıyor; böylece başarısız olan bir test geri kalanları etkilemiyor. Bu blogun Markdown çeviricisinin testleri şöyle görünüyor:
fn test_plain_and_slug() =
assert_eq(md_plain("# Başlık\n\nBir **iki**, `üç`"), "Başlık Bir iki, üç")
assert_eq(slugify("Rill ile Çalışma Zamanı: 101!"), "rill-ile-calisma-zamani-101")
$ rill test src/md.rill
ok test_blocks
ok test_plain_and_slug
2 tests: 2 passed, 0 failed
Ayrıca doküman yorumlarındaki örnekler de birer test. Dört boşlukla girintilenmiş bir yorum satırı bir örnek oluyor; # => sonrasında beklenen çıktı yazılıyor ve rill doctest bunları çalıştırıp denetliyor. Standart kütüphanedeki her fonksiyonun böyle bir örneği var, yani referans sayfalarında gösterilen şey kodun gerçekten yaptığı şey.
Neler zor oldu, neler öğrendim?
Rill'i yazarken en çok zaman aldığım konular, ilk bakışta en basit görünenler oldu:
- Ondalıklı sayıların toplanma sırası. Bir
Floattoplamını vektör şeritlerine bölmek toplama sırasını değiştirir ve bu da son bitlerde cevabı değiştirir. Rill+'nın yazıldığı anlama gelmesini istiyor, bu yüzden varsayılan olarak bunu yapmıyor. Yeniden sıralamayı kabul eden bir fonksiyon bunufast fnile açıkça söylüyor; bir iç çarpım böylece beş kat hızlanıyor. - Kuyruk çağrısı sözünün sınırı. Söz makinenin yazmaçları üzerinden veriliyor. Altıdan fazla tam sayı ya da işaretçi parametresi C çağrı kuralına düşüyor. Birbirini çağıran böyle fonksiyonlar için derleyici uyarıyor; çözüm fazlalıkları tek bir demette toplamak, çünkü bir demet tek bir işaretçi.
- Karakter ile bayt farkı.
#s,s[i]ves[i:j]bayttır ve öyle kalıyor; bir sözcük çözümleyici ve bir ağ protokolü bayt ister."günaydın"on bayt ama sekiz harf. Karakterler isimlerinde bunu söyleyen fonksiyonlarla (char_count,char_take) üst katmanda yaşıyor. - Tek kanonik yazım.
rill fmt'nin her programa tek bir yazım vermesi, dilin okunmasını, diff'lenmesini ve kod üretilmesini öngörülebilir kılıyor. Bu blogun şablon üreticisi de bundan faydalanıyor.
Bir dili yazmanın en iyi testi, onunla gerçek bir şey yazmak. Bu blog, Rill'in "sunucu yazmaya yetecek kadar G/Ç" iddiasının benim için ilk gerçek sınavı oldu.
Sonuç
Rill; ML ailesinin tip güvenliğini, Go'nun strand ve kanal modelini ve C'ye yakın bir performansı, çöp toplayıcı olmadan bir araya getirmeye çalışan bir deney. Tip çıkarımı sayesinde kod bir betik dili kadar kısa kalıyor. Derleme zamanı referans sayımı sayesinde bellek davranışı öngörülebilir ve rill explain ile görünür. Kuyruk çağrısı garantisi sayesinde döngüler fonksiyon olarak yazılabiliyor ve strand'ler o kadar ucuz ki bağlantı başına bir tane açmak bir sorun olmaktan çıkıyor.
Sırada daha fazla platform (RISC-V ve ESP32 üzerinde ilk denemeler çalışıyor), daha zengin bir standart kütüphane ve dil sunucusunun yetenekleri var. Sorularınızı ya da denemelerinizi bana X ya da LinkedIn üzerinden iletirseniz sevinirim. Bir sonraki yazıda bu blogun arkasındaki veritabanı OxiDB'yi ve tek bir süreçte dört farklı veri modelini nasıl sunduğunu anlatacağım.