Teknik Parsing, Bukan Validasi (2019)
(lexi-lambda.github.io)- Dalam desain berbasis tipe, dibanding validasi yang hanya memeriksa input lalu membuang hasilnya, parsing yang menyimpan hasil pemeriksaan sebagai tipe yang lebih presisi akan meningkatkan keamanan kode setelahnya
- Fungsi seperti
head :: [a] -> ayang gagal pada sebagian input bisa dibuat dengan tipe hasil yang dilemahkan, tetapi pemanggil akhirnya harus terus menanggung cabang kegagalan yang sebenarnya tidak perlu NonEmpty amempertahankan keadaan daftar tidak kosong di dalam tipe, sehingga mengurangi pemeriksaan berulang dan penanganan kesalahan yang “mustahil terjadi”- Jika validasi sementara dicampur di berbagai bagian kode pemrosesan, itu menjadi shotgun parsing, sehingga kesalahan input bisa baru ditemukan setelah sebagian perubahan status sudah terjadi
- Dalam praktik, representasi data yang diinginkan sebaiknya lebih dulu dimasukkan ke signature fungsi, lalu invarian dinaikkan ke batas tipe dengan
Map, tipe abstrak, smart constructor, dan sebagainya
Titik awal desain berbasis tipe
- “Parse, don’t validate” adalah ungkapan tiga kata yang merangkum desain berbasis tipe
- Sistem tipe statis memperlihatkan pertanyaan “bisakah fungsi ini ditulis” bahkan sebelum kode ditulis
- Dalam contoh Haskell,
foo :: Integer -> Voidtidak bisa menghasilkan nilai nyata karenaVoidtidak memiliki nilai head :: [a] -> ajuga tidak terdefinisi untuk semua input karena daftar kosong[]bisa masuk- GHC memberi peringatan bahwa pattern matching tidak menangani
[] - Ini adalah fungsi parsial yang tidak terdefinisi untuk semua input yang mungkin
- GHC memberi peringatan bahwa pattern matching tidak menangani
Dua cara mengubah fungsi parsial menjadi fungsi total
-
Melemahkan tipe hasil
- Jika diubah menjadi
head :: [a] -> Maybe a, fungsi ini menjadi total karena dapat mengembalikanNothingsaat daftar kosong - Implementasinya mudah, tetapi pemanggil harus selalu menangani kemungkinan
Nothing - Muncul contoh ketika variabel lingkungan
CONFIG_DIRSdibaca dan sudah diperiksa tidak kosong, tetapi dimaincabangNothingdari hasilheadtetap harus ditangani lagi - Pemeriksaan berulang membuat kode berantakan dan, dalam kasus kompleks, dapat menumpuk menjadi biaya performa
- Bahkan jika pemeriksaan di tahap awal dihapus, kesalahan di bagian belakang yang “mustahil terjadi” tetap tidak tampak dalam tipe
- Pada akhirnya, sistem tipe jadi berlubang, dan penemuan bug bergantung pada pengujian atau peninjauan manual
- Jika diubah menjadi
-
Memperkuat tipe argumen
- Jika alih-alih melemahkan tipe hasil kita memperkuat tipe argumen, kemungkinan
headdipanggil pada daftar kosong bisa dihilangkan NonEmpty adariData.List.NonEmptymerepresentasikan daftar yang tidak kosong- Definisinya adalah
data NonEmpty a = a :| [a] - Elemen pertama
adan sisa daftar[a]dipisahkan, sehingga meskipun ekornya kosong, elemen pertama selalu ada head :: NonEmpty a -> adapat diimplementasikan hanya dengan satu pattern dan menjadi fungsi total- Jika tipe hasil diubah menjadi seperti
getConfigurationDirectories :: IO (NonEmpty FilePath), fakta bahwa nilainya tidak kosong tetap terjaga di dalam tipe nonEmpty :: [a] -> Maybe (NonEmpty a)mengubah daftar biasa menjadiNonEmpty- Penanganan
Nothingdilakukan satu kali saja di batas input - Di
main, nilai itu bisa dipakai sepertiinitializeCache (head configDirs)tanpa cabang berulang - Jika kemudian
getConfigurationDirectoriesdiubah sehingga tidak lagi menjamin hasilnya tidak kosong, tipe hasilnya juga harus berubah, danmainakan gagal pada pemeriksaan tipe
- Jika alih-alih melemahkan tipe hasil kita memperkuat tipe argumen, kemungkinan
Perbedaan validasi dan parsing
validateNonEmpty :: [a] -> IO ()danparseNonEmpty :: [a] -> IO (NonEmpty a)sama-sama memeriksa daftar kosong dan menghasilkan error saat gagal- Perbedaannya ada pada tipe hasil
validateNonEmptymengembalikan()yang tidak membawa informasi, sehingga hasil pemeriksaan dibuangparseNonEmptymengembalikanNonEmpty a, sehingga pengetahuan yang diperoleh dari pemeriksaan tetap disimpan dalam sistem tipe
- Parser dapat dipandang sebagai fungsi yang mengonsumsi input yang kurang terstruktur dan menghasilkan output yang lebih terstruktur
- Dalam definisi ini,
parseNonEmptyadalah parser sederhana yang mem-parsing daftar menjadi daftar tidak kosong - Parsing menyelesaikan pemeriksaan lebih dulu di batas antara program dan dunia luar, sehingga pemeriksaan yang sama tidak perlu diulang sesudahnya
Batas parsing dalam ekosistem Haskell
- Aplikasi Haskell menggunakan berbagai jenis parser di titik tempat ia bertemu dunia luar
- aeson: menyediakan tipe
Parseruntuk mem-parsing data JSON menjadi tipe domain - optparse-applicative: menyediakan parser combinator untuk argumen command line
- persistent, postgresql-simple: menyediakan mekanisme untuk mem-parsing nilai dari penyimpanan data eksternal
- servant: mem-parsing tipe data Haskell dari komponen path, parameter kueri, header HTTP, dan sebagainya
- aeson: menyediakan tipe
- Dunia luar tidak berbicara dalam product type dan sum type, melainkan dalam aliran byte, sehingga parsing tidak bisa dihindari
- Dengan mem-parsing data di tahap awal sebelum digunakan, banyak jenis bug dapat dihindari, dan sebagian bahkan bisa berujung pada kerentanan keamanan
- Untuk mem-parsing semuanya di depan, nilai mungkin harus di-parse jauh lebih awal daripada saat benar-benar dipakai
- Dalam sistem tipe statis, ketika logika parsing dan logika pemrosesan tidak selaras, program tidak akan bisa dikompilasi
Risiko pendekatan yang berpusat pada validasi
- Validasi ad hoc dapat mengarah pada shotgun parsing sebagaimana dibahas dalam bidang language-theoretic security
- Dalam makalah tahun 2016 The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them, shotgun parsing adalah antipola ketika parsing dan kode validasi input tersebar dan bercampur dengan kode pemrosesan
- Jika semua input tidak di-parse di awal, program bisa memproses sebagian input yang valid lalu baru belakangan menemukan kesalahan di bagian lain
- Dalam kasus ini, perubahan status yang sudah terjadi harus dibatalkan
- Kadang rollback dimungkinkan seperti pada transaksi RDBMS, tetapi secara umum tidak selalu demikian
- Pendekatan berbasis validasi membuat sulit atau mustahil memastikan bahwa semua validasi benar-benar sudah selesai di depan
- Parsing membagi program menjadi fase parsing dan fase eksekusi, sehingga kegagalan akibat input tidak valid dibatasi pada fase pertama
Cara menerapkannya dalam praktik
- Rancang dengan lebih dulu menuliskan representasi data yang diinginkan pada type signature fungsi, lalu menjembatani perbedaannya dengan representasi yang saat ini tersedia
- Jika sebuah fungsi menerima daftar
[(k, v)]yang tidak boleh mengizinkan kunci duplikat, pemeriksaan terpisah seperticheckNoDuplicateKeys :: ... => [(k, v)] -> m ()mudah terlewatkan - Cara yang lebih baik adalah menerima
Mapsebagai argumen fungsi, karena secara struktural ia tidak mengizinkan kunci duplikat- Titik pemanggilan bisa gagal pada pemeriksaan tipe
- Ini mendorong konversi daftar menjadi
Mapnaik ke atas sepanjang rantai pemanggilan - Saat mencapai lokasi tempat nilai dibuat atau lokasi tempat duplikasi memang harus diizinkan, barulah tambahkan pemeriksaan berbentuk
[(k, v)] -> m (Map k v)
- Dalam kondisi ini, hasil pemeriksaan diperlukan untuk eksekusi berikutnya, sehingga pemeriksaan itu tidak bisa dihilangkan
- Ada dua prinsip yang terus berulang
- Gunakan struktur data yang membuat keadaan yang mustahil tidak dapat direpresentasikan
- Dorong beban pembuktian sejauh mungkin ke atas, tetapi jangan lebih jauh dari titik yang memang diperlukan
Panduan desain tambahan dan batasannya
- Biarkan tipe data memandu kode, dan hindari godaan menambahkan
Boolke record hanya karena fungsi yang sedang ditulis saat ini membutuhkannya - Fungsi yang mengembalikan
m ()patut dicurigai- Fungsi seperti itu bisa diperlukan saat hanya melakukan efek imperatif tanpa hasil yang bermakna
- Tetapi jika tujuan utamanya adalah melempar error, kemungkinan ada cara yang lebih baik
- Tidak perlu takut mem-parsing data dalam beberapa tahap
- Menghindari shotgun parsing bukan berarti tidak boleh melakukan apa pun terhadap input sebelum semuanya ter-parse sempurna
- Sebagian input tetap dapat digunakan untuk menentukan cara mem-parsing input lainnya
- Representasi data yang terdenormalisasi sebaiknya dihindari, terutama jika bisa diubah
- Jika data yang sama disalin ke banyak tempat, keadaan yang saling tidak selaras jadi mudah direpresentasikan
- Jika denormalisasi benar-benar diperlukan, sembunyikan di balik batas abstraksi agar hanya modul tepercaya kecil yang bertanggung jawab atas sinkronisasi
- Ketika alat Haskell saja sulit untuk benar-benar mengekspresikan invarian tertentu,
newtypeabstrak dan smart constructor bisa digunakan agar validator bertindak seperti parser - Tidak perlu memperkenalkan singletons dan merombak seluruh aplikasi hanya untuk menghapus semua
error "impossible", tetapi untuk kasus seperti itu invarian tetap harus diperlakukan dengan hati-hati, misalnya dengan menuliskannya sebagai komentar
Bacaan lanjutan dan catatan realistis
- Untuk memanfaatkan sistem tipe Haskell dengan baik, tidak harus memiliki PhD atau memakai ekstensi bahasa GHC terbaru
- Titik awalnya mendekati prinsip sederhana, yaitu “tulislah fungsi total”, tetapi menerapkannya pada kode nyata tidak selalu mudah
- Komunitas Haskell yang kecil membuat pola desain dan teknik sering tersisa sebagai pengetahuan lisan, bukan dokumentasi
- Bahan terkait termasuk Type Safety Back and Forth karya Matt Parson
- Untuk topik yang lebih lanjut, makalah Matt Noonan tahun 2018 Ghosts of Departed Proofs membahas teknik memasukkan invarian yang lebih kompleks ke dalam sistem tipe
- Dalam program nyata, invarian tertentu mungkin sulit dimasukkan ke dalam sistem tipe, dan prinsip-prinsip ini lebih dekat pada cita-cita yang dituju daripada persyaratan yang kaku
1 komentar
Pendapat Hacker News
Nasihat yang sangat bagus dan tulisan yang luar biasa. Ada alasan mengapa tulisan ini sesekali muncul lagi di situs ini.
Bahkan bagi orang yang tidak memakai bahasa fungsional bertipe statis, ide ini melampaui paradigma. Dalam literatur berorientasi objek era 80–90-an, misalnya Design by Contract, kita juga bisa melihat konsep yang sangat mirip, dan mungkin bisa menemukan makalah, diskusi, serta spesifikasi yang lebih lama lagi.
Menurut saya TypeScript juga sering ditulis dengan cara mempersempit tipe saat runtime. Design by Contract tampaknya juga memengaruhi spec di Clojure, sebuah bahasa dinamis.
Pada dasarnya ini adalah soal asumsi dan jaminan. Jika kita bisa memeriksa suatu asumsi dan menghasilkan jaminan, bagian lain dari program tidak perlu memeriksa asumsi yang sama lagi.
Saat membaca kode, hal yang paling membingungkan adalah melihat properti yang sudah dijamin malah diperiksa lagi di tempat lain. Itu membuat penalaran dan perbaikan jadi lebih sulit.
Secara statistik hal seperti itu pada akhirnya akan terjadi, dan saat itu proses, skrip, atau kode lain yang bergantung pada prosedur validasi “asli” akan berada dalam kesulitan besar.
Namun harus benar-benar dipakai. Misalnya, sediakan kelas
UncheckedEmail,ValidEmail, danVerifiedEmail, lalu buat agar perpindahan dari satu tahap ke tahap berikutnya harus selalu melewati proses verifikasi email.Dengan begitu kita tidak perlu menebak apakah alamat email belum diperiksa, valid secara format, atau sudah terverifikasi, dan tidak perlu boolean seperti
is_email_verifiedyang bisa lupa diperbarui atau diperiksa. Jika nilai yang salah dipakai di tempat yang salah, type checker akan berteriak, dan manusia bisa fokus pada hal-hal penting.Karena itu ada yang mengira penulisnya mengatakan jangan melakukan validasi sama sekali dan hanya lakukan parsing, padahal tulisan aslinya membahas di mana data divalidasi dan apa yang dilakukan dengan hasilnya. Ini bukan tulisan yang mengusulkan penghapusan semua validasi.
Ini tulisan dari 2019, tetapi nasihatnya masih cukup bagus. Pola ini sangat cocok juga di C# modern, dan bisa menghemat ruang karena deklarasi variabel eksplisit dapat dihilangkan.
if(!Whatever.TryParse(input, out var output)) output = some-sane-default;atau
if(!Whatever.TryParse(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");Tips profesional: yang kedua jangan dilakukan di driver kernel mode.
Penanganan eksplisit selalu lebih baik daripada nilai default implisit yang dipakai sebagai pengganti saat nilai yang dianggap benar ternyata salah.
Yang seharusnya dilakukan adalah sejak awal angkat tangan dan perlakukan itu sebagai kegagalan parsing, lalu definisikan proses dan protokol untuk menangani file yang tidak bisa dimuat dengan sangat jelas. Dengan begitu, Anda akan memaksa diri sendiri menanyakan pertanyaan-pertanyaan sulit yang tidak ditangani oleh dua pilihan di atas.
Masalah sebenarnya pada driver kernel mode CrowdStrike baru-baru ini yang gagal mem-parsing suatu file def/config adalah para developer, product owner, dan business analyst tidak bertanya, “Apa yang terjadi jika kita mencoba memuat file yang tidak valid?”
Penanganan eksplisit > penanganan implisit
if(!Whatever.TryParse(input, out var output)) output = some-sane-default;Saya sangat tidak suka cara ini. Menurut saya error input yang tidak valid harus ditangani di luar fungsi parsing. Di F#, itu mudah.
type Whatever =static member create input =match input with| ValidWhatever x -> Some x| _ -> Nonematch Whatever.create input with| Some x -> // menangani data yang sudah di-parse| None -> // menangani kasus yang tidak berhasil di-parse dengan benarAtau Anda juga bisa membuat pipeline untuk menangani operasi berantai dengan lebih nyaman menggunakan
Option.map/Option.bind.Dengan begini, instance hanya bisa dibuat melalui metode
createyang mem-parsing input.Namun dalam praktiknya kemungkinan besar Anda ingin memakai
resultketimbangoption, tetapi itu cerita sampingan.if(!Whatever.TryParse(input, out var output)) output = some-sane-default;.Jika input sama sekali tidak diberikan, yaitu parameternya opsional, memakai nilai default yang masuk akal memang wajar.
Namun jika input yang salah diberikan, jangan berpura-pura seolah tidak ada masalah.
Jika seseorang masuk ke toko bunga dan meminta kopi, jawaban yang benar bukanlah menyerahkan mawar. Kalau orang itu mencoba meminumnya, mulutnya akan terkoyak.
Untuk himpunan input tersebut, metode, modul, atau program tidak memiliki output yang terdefinisi. Fakta itu harus ditunjukkan dengan jelas, alih-alih diam-diam melakukan hal yang salah atau tidak jelas sehingga program cepat menjadi mustahil dinalar. Jangan biarkan itu baru tertangkap berbulan-bulan kemudian sebagai bug perilaku aneh; lebih baik buat masalahnya muncul dengan jelas dan tinggalkan stack trace yang langsung mengarah ke titik masalah, demi kebaikan Anda sendiri.
Ini adalah saran untuk memanfaatkan sistem tipe yang kuat agar keadaan error tidak mungkin direpresentasikan. Sangat bagus untuk mengurangi bug di seluruh perangkat lunak.
Memikirkan masalah lebih dalam dan membuat desain seperti ini memang butuh waktu lebih lama, tetapi dalam banyak kasus waktu itu sangat sepadan.
Tentu saja, kalau menggunakan bahasa yang membutuhkan banyak prosedur sadar untuk memodelkan data, seperti C++, Java, C#, Python, Go, atau JavaScript, waktunya akan lebih lama.
“Sekarang saya punya slogan pendek dan kuat yang merangkum apa arti desain berbasis tipe bagi saya, dan yang lebih baik lagi, hanya terdiri dari tiga kata: Parse, don’t validate.”
Slogan saya justru lebih dekat ke selalu lakukan validasi hanya di satu konstruktor. Konstruktor itu boleh saja berupa fungsi konstruktor.
Dengan begitu, objek yang tidak valid sejak awal tidak bisa ada, dan selalu ada satu sumber kebenaran tunggal. Jika ingin memodifikasi objek, implementasikan dengan memanggil konstruktor yang sama lagi untuk membuat keadaan baru.
Intinya adalah jika hanya melakukan validasi, informasi itu nantinya hilang.
Misalnya, hanya memvalidasi apakah suatu
intpositif memiliki manfaat terbatas. Jika nilai itu tidak di-parse menjadi bilangan bulat positif, informasi tersebut tidak tersisa di level tipe setelahnya. Hal yang sama berlaku untuk array atau list yang tidak kosong, sehingga konsumen berikutnya mungkin harus memeriksa lagi apakah list itu benar-benar tidak kosong.Informasi semacam ini tidak selalu bisa dienkodekan ke dalam objek atau konstruktor.
Materi terkait: Making Impossible States Impossible oleh Richard Feldman
https://www.youtube.com/watch?v=IcgmSRJHu_8
Sebelumnya juga pernah ada diskusi-diskusi yang bagus.
https://news.ycombinator.com/item?id=35053118
https://news.ycombinator.com/item?id=21476261
Setiap kali topik ini muncul, saya teringat bagian 5 dari https://cr.yp.to/qmail/guarantee.html. Di sana ada kalimat seperti “jangan lakukan parsing” dan “ada dua jenis antarmuka perintah di dunia komputasi: antarmuka yang baik dan antarmuka pengguna.”
Kalau saya mengajar kelas tentang pemrograman skala menengah, bukan skala kecil atau besar, saya ingin memberi tugas kepada mahasiswa untuk menulis esai yang membandingkan dan mengontraskan usulan-usulan ini. Masing-masing punya hal yang bisa dipelajari, dan mungkin tidak sebertentangan kelihatannya pada pandangan pertama.
Saya teringat komentar yang pernah saya lihat pada masa tren XML di pertengahan 2000-an. Isinya kurang lebih bahwa alasan banyak organisasi mengimplementasikan bahasa khusus domain, termasuk bahasa konfigurasi, dengan XML mungkin karena XML menyediakan parser, dan sebagian besar organisasi tidak ingin menulis parser sendiri.
Saya tidak tahu mengapa orang-orang enggan menulis parser. Membuat parser tidak terlalu sulit dan cukup menyenangkan.
Ini salah satu tulisan favorit yang saya baca sepanjang karier saya. Saya sering melihat orang hanya membaca judulnya lalu berasumsi bahwa parsing dan validasi somehow saling eksklusif, padahal sebenarnya tidak begitu. Parsing sering kali mencakup validasi.
Hal ini dibahas di bagian “Use abstract datatypes to make validators ‘look like’ parsers” dalam tulisan tersebut.
Ini berada di ranah yang sama dengan gagasan untuk menghindari obsesi pada tipe primitif.