June 23, 2012 - Tagged as: lisp, lua, tr.
Son zamanlarda uğraştığım konulardan biri hakkında birşeyler karalayacağım. Genelde zor bir konspetle karşılaştığımda anladığımı anlamak için 2 şey yapıyorum, 1) programlıyorum, 2) anlatıyorum. Uğraştığım işlerle ilgilenen kimse olmadığından, anlatabiliyor olmamı bu blog sağlıyor. Bu sefer Scheme’in call/cc fonksiyonundan bahsedeceğim, epey ilginç bir iş yaptığını düşünüyorum.
call/cc
de benim ilgimi çeken şey, Scheme’i bir yorumlayıcı ortamı olarak düşündüğümüzde, yorumlayacağımız dile exceptionlar, coroutineler gibi kontrol akışınıda değişiklikler yapması gereken yapıları kolayca ekleyebilmemizi sağlaması.
Continuation-passing style’a aşina olduğunuzu varsayıyorum. Aşina olmayanlar Google’dan kolaylıkla süper kaynaklar edinebilir. JavaScript gibi yaygın bir dilin CPS kullanabilmek için gereken fonksiyonelliği sağlıyor olması çok büyük şans, yoksa CPSı anlamak için Scheme kodu okumak zorunda kalabilirdiniz :P . Ben implementasyonu Common Lisp ile vereceğim.
call-with-current-continuation
veya kısaca call/cc
nin genel olarak iki farklı implementasyon yöntemi var1 biri stack kopyalama işlemi, diğeri CPS conversion dediğimiz, benim birazdan anlatım Common Lisp ile yazacağım yöntem.
Tüm programlarımızın üstü kapalı bir şekilde(implicit) CPS yazıldığını düşünelim. Yani tüm fonksiyon çağrıları aslında bir değer dönmek yerine, bu döneceği değer ile continuation’ı çağırmalı2 Bunu sağlamak için tüm fonksiyonlar ilk parametre olarak continuation alabilir ve diğer parametreleri fonksiyon tanımı sırasında verilen parametreler olur. Daha sonra fonksiyon döneceği değer ile ilk parametresini, yani continuation’ı çağırır. Bu fonksiyonları bir macro ile kolayca oluşturabiliriz3:
defmacro defcont (name (&rest params) &body body)
(let ((result (gensym)))
(defun ,name (continuation ,@params)
`(let ((,result ,@body))
(funcall continuation ,result))))) (
Bu macronun syntaxı defun
un tamamen aynısı, sadece ekstradan ilk parametre olarak continuation alıyor ve dönüş değeriyle aslında continuation’ı çağırıyor. Bu şekilde yazılan fonksiyonlar CPS conversion a maruz kalıyor yani.
Örnek olarak bu şekilde basit bir toplama ve çarpma fonksiyonları oluşturalım:
rest args)
(defcont multiply (&apply #'* args))
(rest args)
(defcont add (&apply #'+ args)) (
Burda fonksiyonların içinde CPS formatında olmayan, dilin kendi fonksiyonlarını çağırıyoruz ama bu sorun değil.
Bu durumda normalde (* 3 (+ 1 2))
şeklinde yazacağımız fonksiyonu şu şekilde yazmamız gerekiyor: (add (lambda (r) (multiply #'identity r 3)) 1 2)
. İşte bu dönüşümü yapmaya CPS conversion
diyoruz. Ne yaptığına bakalım, çarpma işleminin sonucu (+ 1 2)
çağrısının dönüş değerine bağlı, buna göre ilk başta toplama işlemi yapılıp, sonucu çarpma işlemine aktarılmalı. Çarpma işlemi de toplama işleminin sonucunu alıp, 3 ile çarptıktan sonra bir continuation’a aktarmalı. Burda bu son continuation olarak identity
fonksiyonunu seçtim ki sonucu elde edebilelim. Normalde, eğer örneğin sonucu yazdırmak istiyorsak, orata prin1
gibi bir fonksiyon göndermemiz gerekir.
Bu şekilde yazılan programların nasıl çalıştığına bakarsanız, aslında fonksiyon çağrıları için stack modeline ne kadar benzediğini farkedersiniz. Stack modelinde, çarpma fonksiyonu çağırıldığında stack’de fonksiyon çağrısı hakkında gerekli verileri tutan bir kayıt oluşturulacak, daha sonra toplama işlemi çağırıldığında bunun üzerine bir kayıt daha eklenecek, ve fonksiyon çağrıları bittikçe bu kayıtlar stackten toplanarak bir alt seviyeye dönüş değerlerini bir şekilde aktaracak.
CPS’de tamamen aynı, toplama işlemi önce bitecek ve çarpma işlemine dönüş değerini aktaracak. Bunun için çarpma fonksiyonunu ilk parametresi olarak alacak, çarpma fonksiyonu da kendi sonucunu hangi fonksiyona aktaracaksa o fonksiyonu ilk parametre olarak alacak gibi.
Buraya kadar herşey anlaşıldıysa, call/cc
yi yazmak çok kolay. call/cc
nin yaptığı, o anki continuation’ı açık bir şekilde kullanıcıya vermek. Normalde yukarıda bahsettiğim tüm olaylar derlenme aşamasında CPS conversion veya başka yöntemlerle hallediliyor ve kullanıcı aslında fonksiyonlarını bizim örneğimizdeki gibi yazmıyor. Dolayısıyla fonksiyonuna aktarılan continuation’a erişme şansı yok. Fakat call/cc
hariç, call/cc
tam olarak bu işi yapıyor, o anki continuation’a erişim izni veriyor. Şu şekilde:
defun call/cc (continuation fun)
(funcall fun continuation)) (
Bu fonksiyonun defcont
ile tanımlandmadığına dikkat. Bu şu anlama geliyor, programlama dili böyle bir fonksiyon sunmuyorsa, bu fonksiyonun yazılması imkansız. Kullanıcının tek sahip olduğunun defcont
olduğunu düşünün, yani tüm fonksiyonları _CPS conversion_a mağruz kalıyor, ve fonksiyon çağrıları otomatik olarak CPS’e dönüştürülüyor. Programcı yazdığı fonksiyonlara continuation’ın nasıl aktarıldığını bilmiyor veya bilse bile buna erişmesinin hiçbir yolu yok.
call/cc
, kendi aldığı continuation’ı fun
parametresi olarka aldığı fonksiyona aktarıyor, ve daha sonra fun
fonksiyonu o continuation ile her türlü çılgınlık yapabilir, örneğin bu continuation’ı bir yere kaydedip, bir daha bir daha çağırmak gibi. Bu continuation, dönüş değerinin aktarılacağı fonksiyonu tutuyor aslında.
Hemen basit birkaç örnek yapalım. Şu iki fonksiyon arasındaki fark ne?
lambda (r) (+ r 10)) (lambda (cont) (funcall cont 11)))
(call/cc (lambda (r) (+ r 10)) (lambda (cont) 11)) (call/cc (
İlk çağrıda continuation’a 11 değerini gönderiyoruz ve sonuç beklenen gibi 21 oluyor, ikinci durumda ise continuation’ı yok sayıp 11 değerini dönüyoruz ve cevap 11 oluyor. Stack modelinde düşünürsek, ikinci örnekte yapılan şey, stackdeki bazı fonksiyon kayıtlarının atlanması aslında. Buna sanırım stack unwinding
deniyor(emin değilim). Bu şekilde dil seviyesinde exception mekanizmaları yazılabilir, continuationlar kaydedilerek ve sırayla çağırılarak coroutineler4veya _lightweight thread_ler elde edilebilir(green thread de diyorlar sanırım).
Scheme bilenler için bu kodun Scheme karşılıkları şu:
call/cc (lambda (cont) (+ 10 11)))
(call/cc (lambda (cont) (+ 10 (cont 11)))) (
Aralarındaki fark, Scheme kodu _CPS conversion_a mağruz kalmamış, Common Lisp kodu ise kalmış hali.
Bu aşamada yapılabilecek çok fazla fantastik iş var ve çoğu durumda neler olup bittiğini anlama çok güç. Zaten bu yüzden GOTO
larla karşılaştırılıyorlar bazen. Benim hoşuma giden bir kullanımını şu:
defvar creturn nil)
(lambda (r) (+ 1 r))
(call/cc (lambda (cont)
(setf creturn cont)
(1))
Continuation’ı bir değişkene kaydediyorum ve daha sonra istediğim zaman o continuation’ı çağırıp işlemi kaldığı yerden devam ettiriyorum.
funcall creturn 15)
CL-USER> (16
funcall creturn 20)
CL-USER> (21
Kısaca, işlemi istediğim bir yerden durdurdum ve kopyaladım. Daha sonra durdurduğum andan itibaren istediğim bir değer ile devam ettiriyorum. Müthiş bir olay. Bu gösterdiğim örnekler en basit ve sıradan örnekler, neler yapılabileceği hakkında çok fazla güzel kaynak var, açıkçası ben çoğunun ne yaptığını anlamakta güçlük çekiyorum, bazı patternlara aşina olmak gerekiyor. call/cc
nin Scheme’den kaldırılması da şu ortamda epey tartışılmış.
Çok bilinen iki implementasyon yöntemi demiştim, diğer yöntem de stack kopyalama. Bu örneği düşünelim, call/cc
aslında stack’in o anki durumunu aktarıyor aslında. Burda yapılan işlem büyük ihtimalle en baştan beri tüm stackin kopyalanması değil. Sphagetti stack gibi bir yapı kullanılıyor olabilir.
Bu arada, coroutine demişken, implementasyon detaylarını merak eden varsa: Coroutines in Lua. Lua’yı seviyoruz.
Aslında, birkaç tane çok bilinen(yazılmış) implementasyonu var ve dahası da gereksinimlere/şartlara göre uydurulabilir. Detayları merak eden varsa: Three Implementation Models for Scheme. Ve büyük ihtimalle şu kitapta da bahsediliyordur: Lisp in Small Pieces↩︎
Bu durumda, sanırım, teorik olarak stack denen yapıya ihtiyaç kalmıyor.↩︎
Bu macroda bir sorun var fakat konumuzun dışında olduğundan, işleri karıştırmamak için önemsemedim: continuation
parametresi variable capturea maruz kalabilir. gensym
ile parametre adı oluşturulması gerekir.↩︎
Şu anda coroutineleri implement etmek için sanırım zaten stackin bir kopyasını çıkarmak zorundasınız.↩︎