3 puan yazan GN⁺ 2024-07-23 | 1 yorum | WhatsApp'ta paylaş
  • Tip odaklı tasarımda, girdiyi yalnızca kontrol edip atan doğrulama yerine, kontrol sonucunu daha hassas bir tip olarak bırakan ayrıştırma, sonraki kodun güvenliğini artırır
  • head:: [a] -> a gibi bazı girdilerde başarısız olan bir fonksiyonda dönüş tipi zayıflatılabilir; ancak bu, çağıranın gereksiz başarısızlık dallarını sürekli üstlenmesine yol açar
  • NonEmpty a, boş liste olmama durumunu tipte koruyarak tekrar eden kontrolleri ve “asla gerçekleşmeyecek” hata işlemeyi azaltır
  • İşleme kodunun çeşitli yerlerine geçici doğrulamalar serpiştirilirse bu shotgun parsing olur; giriş hatası ancak bazı durum değişikliklerinden sonra fark edilebilir
  • Pratikte, fonksiyon imzasına önce istenen veri temsilini koymak; Map, soyut tipler, akıllı kurucular gibi araçlarla değişmez koşulları tip sınırına taşımak gerekir

Tip odaklı tasarımın başlangıç noktası

  • “Parse, don’t validate”, tip odaklı tasarımı üç kelimeye sıkıştıran bir ifadedir
  • Statik tip sistemi, “bu fonksiyonu yazabilir miyim?” sorusunu kodu yazmadan önce görünür kılar
  • Haskell örneğinde foo :: Integer -> Void, Void içinde değer bulunmadığı için gerçek bir değer üretemez
  • head :: [a] -> a da boş liste [] alabileceğinden tüm girdiler için tanımlı değildir
    • GHC, örüntü eşlemenin [] durumunu işlemediği konusunda uyarır
    • Olası tüm girdiler için tanımlı olmayan bir kısmi fonksiyondur

Kısmi fonksiyonu toplam fonksiyona dönüştürmenin iki yolu

  • Dönüş tipini zayıflatmak

    • head :: [a] -> Maybe a olarak değiştirilirse boş listede Nothing döndürebilir ve toplam fonksiyon hâline gelir
    • Uygulaması kolaylaşır, ancak çağıran her zaman Nothing olasılığını ele almak zorunda kalır
    • CONFIG_DIRS ortam değişkenini okuyup listenin boş olmadığını zaten kontrol ettikten sonra bile, main içinde head sonucunun Nothing dalını yeniden ele almak zorunda kalınan bir örnek verilir
    • Tekrarlı kontroller kodu dağınık hâle getirir; karmaşık durumlarda performans maliyeti olarak da birikebilir
    • Öndeki kontrol kaldırılsa bile arkadaki “asla gerçekleşmeyecek” hata tip üzerinden görünür olmaz
    • Sonuçta tip sisteminde bir delik oluşur ve hataların bulunması testlere ya da elle incelemeye bağımlı hâle gelir
  • Argüman tipini güçlendirmek

    • Dönüş tipini zayıflatmak yerine argüman tipini güçlendirirseniz, headin boş listeyle çağrılma olasılığını ortadan kaldırabilirsiniz
    • Data.List.NonEmpty içindeki NonEmpty a, boş olmayan listeyi temsil eder
    • Tanımı data NonEmpty a = a :| [a] şeklindedir
    • İlk öğe a ile kalan liste [a] ayrılır; kuyruk boş olsa bile ilk öğe her zaman vardır
    • head :: NonEmpty a -> a, tek bir örüntüyle uygulanır ve toplam fonksiyon olur
    • getConfigurationDirectories :: IO (NonEmpty FilePath) gibi dönüş tipi değiştirilirse, boş olmadığı bilgisi tipte korunur
    • nonEmpty :: [a] -> Maybe (NonEmpty a), sıradan bir listeyi NonEmptye dönüştürür
    • Nothing işlemesi giriş sınırında yalnızca bir kez yapılır
    • main içinde initializeCache (head configDirs) gibi tekrar eden dallanmalar olmadan kullanılabilir
    • Daha sonra getConfigurationDirectories boş olmamayı garanti etmeyecek şekilde değiştirilirse dönüş tipinin de değişmesi gerekir ve main tip denetiminde başarısız olur

Doğrulama ile ayrıştırma arasındaki fark

  • validateNonEmpty :: [a] -> IO () ve parseNonEmpty :: [a] -> IO (NonEmpty a) ikisi de listenin boş olup olmadığını kontrol eder ve başarısız olursa hata verir
  • Fark dönüş tipindedir
    • validateNonEmpty, bilgi taşımayan () döndürerek kontrol sonucunu atar
    • parseNonEmpty, NonEmpty a döndürerek kontrolden elde edilen bilgiyi tip sisteminde bırakır
  • Ayrıştırıcı, daha az yapılandırılmış girdiyi tüketip daha yapılandırılmış çıktı üreten bir fonksiyon olarak görülebilir
  • Bu tanıma göre parseNonEmpty, bir listeyi boş olmayan listeye ayrıştıran basit bir ayrıştırıcıdır
  • Ayrıştırma, program ile dış dünya arasındaki sınırda kontrolleri baştan tamamlayıp sonrasında aynı kontrollerin tekrarlanmasını önler

Haskell ekosisteminde ayrıştırma sınırı

  • Haskell uygulamaları, dış dünyayla buluştuğu noktalarda çeşitli ayrıştırıcılar kullanır
    • aeson: JSON verisini alan tiplerine ayrıştıran Parser tipini sağlar
    • optparse-applicative: komut satırı argümanı ayrıştırıcı birleştiricileri sağlar
    • persistent, postgresql-simple: dış veri depolarındaki değerleri ayrıştırmaya yönelik mekanizmalar sağlar
    • servant: yol bileşenleri, sorgu parametreleri, HTTP başlıkları gibi yerlerden Haskell veri tiplerine ayrıştırma yapar
  • Dış dünya çarpım tipleri ve toplam tipleriyle değil bayt akışlarıyla konuştuğu için ayrıştırmadan kaçınılamaz
  • Veriyi kullanmadan önce önde ayrıştırmak, çeşitli hatalardan kaçınmayı sağlar; bunların bazıları güvenlik açıklarına da dönüşebilir
  • Her şeyi başta ayrıştırmak için değerleri, fiilî kullanımdan çok daha erken bir noktada ayrıştırmak gerekebilir
  • Statik tip sisteminde ayrıştırma mantığı ile işleme mantığı birbirinden saparsa program derlenmez

Doğrulama merkezli yaklaşımın riski

  • Geçici doğrulama, language-theoretic security alanında shotgun parsing olarak adlandırılan duruma yol açabilir
  • 2016 tarihli The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them makalesinde shotgun parsing, ayrıştırma ve giriş doğrulama kodunun işleme koduna karışıp dağılması şeklindeki bir anti-pattern olarak tanımlanır
  • Girdinin tamamı baştan ayrıştırılmazsa, program geçerli olan bazı girdileri işledikten sonra başka bir bölümdeki hatayı geç fark edebilir
    • Bu durumda daha önce yürütülen durum değişikliklerini geri almak gerekir
    • RDBMS transaction’ları gibi rollback’in mümkün olduğu durumlar vardır; ancak genel olarak her zaman mümkün değildir
  • Doğrulama tabanlı yaklaşım, tüm doğrulamaların gerçekten başta tamamlanıp tamamlanmadığını kontrol etmeyi zorlaştırır ya da imkânsız kılar
  • Ayrıştırma, programı ayrıştırma aşaması ve yürütme aşaması olarak ikiye ayırır; hatalı girdiden kaynaklanan başarısızlığı ilk aşamayla sınırlar

Pratikte uygulama yöntemi

  • Bir fonksiyonun istediği veri temsilini önce tip imzasına yazıp, mevcut temsil ile arasındaki farkı kapatacak şekilde tasarım yapılır
  • Yinelenen anahtarlara izin verilmemesi gereken bir [(k, v)] listesi alan fonksiyon söz konusuysa, ayrı bir checkNoDuplicateKeys :: ... => [(k, v)] -> m () kontrolü kolayca atlanabilir
  • Daha iyi yöntem, yinelenen anahtarları yapısal olarak izin vermeyen Mapi fonksiyon argümanı olarak almaktır
    • Çağrı noktası tip denetiminde başarısız olabilir
    • Çağrı zinciri boyunca listeyi Mape dönüştürme işi yukarı taşınır
    • Değerin üretildiği konuma ya da tekrarların gerçekten izinli olması gereken noktaya ulaşıldığında [(k, v)] -> m (Map k v) biçiminde bir kontrol eklenir
  • Bu noktada kontrolün sonucu sonraki yürütme için gerekli olduğundan kontrol atlanamaz
  • İki ilke tekrarlanır
    • İmkânsız durumları temsil edilemez kılan veri yapıları kullanmak
    • Kanıt yükünü mümkün olduğunca yukarı taşımak; ancak gereken noktadan daha ileriye zorlamamak

Ek tasarım yönergeleri ve sınırlar

  • Veri tiplerinin kodu yönlendirmesine izin verilmeli; yalnızca şu anda yazılan fonksiyon yüzünden kayda basitçe Bool koyma cazibesinden kaçınılmalıdır
  • m () döndüren fonksiyonlara şüpheyle bakmak gerekir
    • Yalnızca imperatif etkiler gerçekleştiriyor ve anlamlı bir sonuç üretmiyorsa gerekli olabilir
    • Ana amacı hata fırlatmaksa daha iyi bir yöntem olma ihtimali yüksektir
  • Veriyi birkaç aşamada ayrıştırmaktan korkmaya gerek yoktur
    • Shotgun parsing’den kaçınmak, tamamen ayrıştırmadan giriş verisi üzerinde eylem yapmamak anlamına gelir
    • Girdinin bir kısmıyla diğer girdinin nasıl ayrıştırılacağına karar vermek mümkündür
  • Normalleştirilmemiş veri temsillerinden, özellikle değiştirilebilir olduklarında kaçınmak gerekir
    • Aynı veriyi birden fazla yerde kopyalamak, birbirinden sapmış durumların kolayca temsil edilmesine yol açar
    • Normalleştirmeme mutlaka gerekiyorsa bunu bir soyutlama sınırının arkasına gizlemek ve yalnızca küçük, güvenilen bir modüle senkronizasyon sorumluluğu vermek gerekir
  • Haskell araçlarıyla bazı değişmez koşulları gerçekten ifade etmek zor olduğunda, soyut newtype ve akıllı kurucularla doğrulayıcıyı ayrıştırıcı gibi yapmak mümkündür
  • Her error "impossible" kullanımını ortadan kaldırmak için singletons ekleyip tüm uygulamayı yeniden düzenlemek gerekmez; ancak bu tür durumlarda değişmez koşulları yorum olarak bırakmak gibi yollarla dikkatli davranmak gerekir

Daha fazla okuma ve gerçekçi uyarılar

  • Haskell tip sisteminden iyi yararlanmak için PhD ya da en yeni GHC dil uzantıları şart değildir
  • Başlangıç noktası “toplam fonksiyonlar yazın” şeklindeki basit bir ilkeye yakındır; ancak bunu gerçek koda uygulamak kolay olmayabilir
  • Haskell topluluğu küçük olduğundan tasarım kalıpları ve teknikleri belgelerden çok sözlü bilgi olarak kalabiliyor
  • İlgili kaynak olarak Matt Parson’ın Type Safety Back and Forth yazısı vardır
  • Daha ileri bir konu olarak Matt Noonan’ın 2018 tarihli Ghosts of Departed Proofs makalesi, daha karmaşık değişmez koşulları tip sistemine yerleştirme tekniklerini ele alır
  • Gerçek programlarda belirli değişmez koşulları tip sistemine koymak zor olabilir; bu ilkeler katı gereksinimlerden çok yönelilecek ideallere yakındır

1 yorum

 
GN⁺ 2024-07-23
Hacker News görüşleri
  • Çok iyi bir tavsiye ve harika bir yazı. Bu sitede zaman zaman yeniden gündeme gelmesinin bir nedeni var.
    Statik tipli fonksiyonel dil kullanmayanlar için bile bu fikir paradigmaların ötesine geçiyor. 80’ler ve 90’lardaki nesne yönelimli literatürde, örneğin Design by Contract’ta, çok benzer kavramlar görülebilir; daha da geriye giden makaleler, tartışmalar ve spesifikasyonlar da bulunabilir.
    TypeScript’in de çoğu zaman çalışma zamanında tipleri daraltacak şekilde yazıldığını düşünüyorum. Design by Contract’ın dinamik bir dil olan Clojure’daki spec’i de etkilemiş olabileceğini sanıyorum.
    Temelde mesele varsayımlar ve garantiler. Bir varsayımı kontrol edip bir garanti üretebiliyorsanız, programın başka bölümlerinin aynı varsayımı yeniden kontrol etmesine gerek kalmaz.
    Kod okurken en kafa karıştırıcı şey, zaten garanti edilmiş bir özelliğin başka bir yerde yeniden kontrol edildiğini görmektir. Çıkarım yapmayı ve iyileştirmeyi zorlaştırır.

    • O “zaten garanti edilmiş özellik” bir noktada ortadan kalkabilir. Daha doğrusu, o garantiyi uygulayan ve çalıştıran prosedür herhangi bir nedenle artık işini yapmayabilir.
      İstatistiksel olarak böyle şeyler sonunda olur; o zaman da “asıl” doğrulama prosedürüne güvenen diğer süreçler, betikler ve kodlar ciddi sıkıntıya düşer.
    • Güçlü tip sistemine sahip dillerde bu, program büyüyüp karmaşıklaştıkça eninde sonunda özgürlük sağlayan pratik avantajlardan biri hâline gelir.
      Ancak gerçekten kullanmak gerekir. Örneğin UncheckedEmail, ValidEmail, VerifiedEmail sınıfları koyup bir aşamadan diğerine geçmek için mutlaka e-posta doğrulama sürecinden geçmeyi zorunlu kılmak gibi.
      Böylece e-posta adresinin doğrulanmamış mı, biçim olarak geçerli mi, yoksa doğrulanmış mı olduğunu tahmin etmeniz gerekmez; güncellemeyi ya da kontrol etmeyi unutabileceğiniz is_email_verified gibi bir boolean’a da ihtiyaç kalmaz. Yanlış yerde yanlış değeri kullanırsanız tip denetleyici bağırır, insan da önemli işlere odaklanabilir.
    • Eski yazının yorumlarına göz atınca bu yazının en büyük sorunlarından birinin başlık olduğu anlaşılıyor. Başlık bir çapa gibi çalışıyor; birçok kişi metinde olmayan, yalnızca başlığın bağlamdan kopuk biçimde ima ettiği şeye karşı çıkıyor.
      Bu yüzden yazarın hiç doğrulama yapmayıp sadece parse etmeyi önerdiği sanılabiliyor; oysa asıl yazı veriyi nerede doğruladığınız ve bunun sonucunda ne yaptığınız ile ilgili. Tüm doğrulamayı ortadan kaldırmayı savunan bir yazı değil.
  • 2019 tarihli bir yazı ama hâlâ epey iyi bir tavsiye. Bu kalıp modern C#’a da çok iyi uyuyor ve açık değişken tanımı yazmayı atlayabildiğiniz için yer de kazandırıyor.
    if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
    veya
    if(!Whatever.TryParse(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");
    Uzman ipucu: ikincisini kernel mode driver içinde yapmayın.

    • Uzman ipucu: İkisini de yapmayın. Özellikle de ilkini kesinlikle yapmayın.
      Doğru sandığınız değer yanlış çıktığında yerine kullanılan örtük varsayılan değer yerine açık bir işlem her zaman daha iyidir.
      Yapılması gereken, başta el kaldırıp bunu parse hatası olarak ele almak; ardından yüklenemeyen bir dosyayı ele alma sürecini ve protokolünü çok net tanımlamaktır. Böylece yukarıdaki iki seçeneğin ele almadığı zor soruları kendinize sormuş olursunuz.
      Yakın zamandaki CrowdStrike kernel mode driver’ının bir def/config dosyasını parse edememesindeki asıl sorun, geliştiricilerin, ürün sorumlularının ve iş analistlerinin “Geçersiz bir dosya yüklenmeye çalışılırsa ne olur?” diye sormamış olmasıydı.
    • “Epey iyi” olmasının nedeni ne? Ayrıca yazının yayımlandığı yılla bunun ne ilgisi var? 2019’dan daha erken yayımlanmış olsaydı yazıdaki tavsiye daha mı otoriter olacaktı?
    • İlk yöntemi kullanmamanızı isterim. Kötü durumu ele almak gerekir. “Makul bir varsayılan değere” geri dönmek son derece nadir olmalı.
      Açık işlem > örtük işlem
    • if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
      Bu yöntemi gerçekten sevmiyorum. Geçersiz girdi hatasının parse fonksiyonunun dışında ele alınması gerektiğini düşünüyorum. F#’ta bu kolaydır.
      type Whatever =
      static member create input =
      match input with
      | ValidWhatever x -> Some x
      | _ -> None
      match Whatever.create input with
      | Some x -> // parse edilmiş veriyi işle
      | None -> // düzgün parse edilemeyen durumu ele al
      Ya da Option.map/Option.bind ile zincirleme işlemleri yöneten bir pipeline daha rahat kurulabilir.
      Böylece örnekler yalnızca girdiyi parse eden create metodu üzerinden oluşturulabilir.
      Gerçi pratikte option yerine result kullanmak isteme olasılığınız yüksek, ama bu ikincil bir mesele.
    • if(!Whatever.TryParse(input, out var output)) output = some-sane-default; gibi bir kodu görmek isteyeceğim bir durum neredeyse, hatta muhtemelen hiç aklıma gelmiyor.
      Girdinin hiç verilmediği durumda, yani parametre opsiyonelse, makul bir varsayılan değer kullanmak mantıklıdır.
      Ama hatalı girdi verildiğinde hiçbir sorun yokmuş gibi davranılmamasını isterim.
      Biri çiçekçiye girip kahve istediğinde doğru cevap ona gül vermek değildir. Onu içmeye kalkarsa ağzı paramparça olur.
      O girdi kümesi için metodun, modülün veya programın tanımlı bir çıktısı yoktur. Sessizce yanlış ya da belirsiz bir şey yapıp programı hızla akıl yürütülemez hâle getirmektense, bu gerçeği açıkça ortaya koymak gerekir. Aylar sonra tuhaf davranış hatası olarak yakalanmasına izin vermek yerine, sorunu açıkça patlatıp doğrudan problem noktasına götüren bir stack trace bırakmak sizin için de daha iyidir.
  • Güçlü bir tip sisteminden yararlanarak hata durumlarını ifade edilemez hale getirme tavsiyesi. Yazılım genelinde hataları azaltmak için çok iyi
    Sorunu daha derin düşünmek ve böyle bir tasarım yapmak daha fazla zaman alır, ama çoğu durumda bu zaman kesinlikle buna değer

    • Cebirsel veri tiplerini destekleyen bir dilse, bu yaklaşımın daha fazla zaman almadığını rahatça söylerim. Zaten doğal olarak öyle oluyor
      Elbette C++, Java, C#, Python, Go, JavaScript gibi veriyi modellemek için çok fazla bilinçli süreç gerektiren dillerde daha fazla zaman alır
  • “Artık tip odaklı tasarımın benim için ne anlama geldiğini içeren kısa ve güçlü bir slogana sahibim; daha da iyisi, yalnızca üç kelimeden oluşuyor: Parse, don’t validate.”
    Benim sloganım ise daha çok her zaman yalnızca tek bir constructor’da doğrula olurdu. Constructor fonksiyonu da olabilir
    Böyle yaparsanız geçersiz bir nesne baştan var olamaz ve her zaman tek bir doğruluk kaynağınız olur. Nesneyi değiştirmek istiyorsanız, aynı constructor’ı yeniden çağırıp yeni bir durum oluşturacak şekilde uygulayabilirsiniz

    • Aynı şey değil
      Esas nokta, sadece doğrulama yaptığınızda o bilginin daha sonra kaybolması
      Örneğin bir int değerinin pozitif olup olmadığını doğrulamanın faydası sınırlıdır. O değeri pozitif tamsayı olarak parse etmezseniz, sonrasında tip düzeyinde bu bilgi kalmaz. Boş olmayan dizi/listeler için de aynı şey geçerli; sonraki tüketici o listenin gerçekten boş olmadığını tekrar kontrol etmek zorunda kalabilir
      Bu tür bilgiler her zaman bir nesneye ya da constructor’a kodlanamaz
  • İlgili kaynak: Richard Feldman’ın Making Impossible States Impossible konuşması
    https://www.youtube.com/watch?v=IcgmSRJHu_8

  • Daha önce de iyi tartışmalar olmuştu
    https://news.ycombinator.com/item?id=35053118
    https://news.ycombinator.com/item?id=21476261

  • Bu konu her açıldığında https://cr.yp.to/qmail/guarantee.html adresindeki 5. bölüm aklıma geliyor. Orada “parse etmeyin” ve “bilgisayar dünyasındaki komut arayüzleri iki çeşittir: iyi arayüzler ve kullanıcı arayüzleri” gibi cümleler var
    Küçük ölçekli ya da büyük ölçekli değil, orta ölçekli programlamayı öğreten bir ders veriyor olsaydım, öğrencilere bu önerileri karşılaştırıp zıt yönlerini inceleyen bir kompozisyon ödevi vermek isterdim. Her birinden öğrenilecek şeyler var ve ilk bakışta göründüğü kadar çelişkili olmayabilirler

  • 2000’lerin ortasındaki XML modası sırasında gördüğüm bir yorumu hatırlıyorum. Birçok kurumun yapılandırma dilleri dahil alan-özel dilleri XML ile uygulamasının nedeni muhtemelen XML’in bir parser sağlaması ve çoğu kurumun kendi parser’ını yazmak istememesiydi
    İnsanların neden parser yazmak istemediğini bilmiyorum. Parser yazmak o kadar zor değil ve oldukça eğlenceli

  • Kariyerim boyunca okuduğum yazılar arasında en sevdiklerimden biri. İnsanların çoğu zaman yalnızca başlığı okuyup parsing ile doğrulamanın somehow birbirini dışladığını varsaydığını gördüm, ama gerçekte öyle değil. Parsing çoğu zaman doğrulamayı içerir
    Bu konu yazının “Use abstract datatypes to make validators ‘look like’ parsers” bölümünde ele alınıyor
    İlkel tip takıntısından kaçınma fikriyle aynı alanda