Pemrograman Deklaratif: Apakah Ini Hal yang Nyata?
Diterbitkan: 2022-03-11Pemrograman deklaratif, saat ini, merupakan paradigma dominan dari sekumpulan domain yang luas dan beragam seperti database, templating, dan manajemen konfigurasi.
Singkatnya, pemrograman deklaratif terdiri dari menginstruksikan program tentang apa yang perlu dilakukan, alih-alih memberi tahu cara melakukannya. Dalam praktiknya, pendekatan ini memerlukan penyediaan bahasa khusus domain (DSL) untuk mengekspresikan apa yang diinginkan pengguna, dan melindungi mereka dari konstruksi tingkat rendah (loop, kondisional, penugasan) yang mewujudkan keadaan akhir yang diinginkan.
Sementara paradigma ini merupakan peningkatan luar biasa atas pendekatan imperatif yang digantikannya, saya berpendapat bahwa pemrograman deklaratif memiliki keterbatasan yang signifikan, keterbatasan yang saya jelajahi dalam artikel ini. Selain itu, saya mengusulkan pendekatan ganda yang menangkap manfaat pemrograman deklaratif sambil menggantikan keterbatasannya.
PERINGATAN : Artikel ini muncul sebagai hasil dari perjuangan pribadi selama bertahun-tahun dengan alat deklaratif. Banyak klaim yang saya sajikan di sini tidak sepenuhnya terbukti, dan beberapa bahkan disajikan begitu saja. Kritik yang tepat terhadap pemrograman deklaratif akan memakan banyak waktu, tenaga, dan saya harus kembali dan menggunakan banyak alat ini; hatiku tidak dalam usaha seperti itu. Tujuan artikel ini adalah untuk berbagi beberapa pemikiran dengan Anda, tidak menarik, dan menunjukkan apa yang berhasil untuk saya. Jika Anda telah berjuang dengan alat pemrograman deklaratif, Anda mungkin menemukan jeda dan alternatif. Dan jika Anda menikmati paradigma dan perangkatnya, jangan menganggap saya terlalu serius.
Jika pemrograman deklaratif bekerja dengan baik untuk Anda, saya tidak dalam posisi untuk memberi tahu Anda sebaliknya .
Kelebihan Pemrograman Deklaratif
Sebelum kita menjelajahi batasan pemrograman deklaratif, kita perlu memahami kelebihannya.
Bisa dibilang alat pemrograman deklaratif yang paling sukses adalah database relasional (RDB). Bahkan mungkin alat deklaratif pertama. Bagaimanapun, RDB menunjukkan dua properti yang saya anggap tipikal dari pemrograman deklaratif:
- Sebuah bahasa spesifik domain (DSL) : antarmuka universal untuk database relasional adalah DSL bernama Structured Query Language, paling umum dikenal sebagai SQL.
- DSL menyembunyikan lapisan tingkat yang lebih rendah dari pengguna : sejak makalah asli Edgar F. Codd tentang RDB, jelas bahwa kekuatan model ini adalah untuk memisahkan kueri yang diinginkan dari loop, indeks, dan jalur akses yang mendasarinya yang mengimplementasikannya.
Sebelum RDB, sebagian besar sistem basis data diakses melalui kode imperatif, yang sangat bergantung pada detail tingkat rendah seperti urutan catatan, indeks, dan jalur fisik ke data itu sendiri. Karena elemen-elemen ini berubah seiring waktu, kode sering kali berhenti bekerja karena beberapa perubahan mendasar dalam struktur data. Kode yang dihasilkan sulit untuk ditulis, sulit untuk di-debug, sulit dibaca dan sulit untuk dipelihara. Saya akan mengambil risiko dan mengatakan bahwa sebagian besar dari kode ini, kemungkinan besar, panjang, penuh dengan sarang tikus pepatah bersyarat, pengulangan dan bug halus yang bergantung pada negara.
Dalam menghadapi ini, RDB memberikan lompatan produktivitas yang luar biasa bagi pengembang sistem. Sekarang, alih-alih ribuan baris kode imperatif, Anda memiliki skema data yang jelas, ditambah ratusan (atau bahkan hanya puluhan) kueri. Akibatnya, aplikasi hanya harus berurusan dengan representasi data yang abstrak, bermakna, dan tahan lama, dan menghubungkannya melalui bahasa kueri yang kuat namun sederhana. RDB mungkin meningkatkan produktivitas programmer, dan perusahaan yang mempekerjakan mereka, dengan urutan besarnya.
Apa keuntungan umum dari pemrograman deklaratif?
- Keterbacaan/kegunaan : DSL biasanya lebih dekat dengan bahasa alami (seperti bahasa Inggris) daripada pseudocode, sehingga lebih mudah dibaca dan juga lebih mudah dipelajari oleh non-programmer.
- Ringkas : banyak boilerplate diabstraksikan oleh DSL, meninggalkan lebih sedikit baris untuk melakukan pekerjaan yang sama.
- Reuse : lebih mudah untuk membuat kode yang dapat digunakan untuk tujuan yang berbeda; sesuatu yang terkenal sulit saat menggunakan konstruksi imperatif.
- Idempotence : Anda dapat bekerja dengan status akhir dan membiarkan program mencari tahu untuk Anda. Misalnya, melalui operasi upsert, Anda dapat menyisipkan baris jika tidak ada, atau memodifikasinya jika sudah ada, alih-alih menulis kode untuk menangani kedua kasus.
- Pemulihan kesalahan : mudah untuk menentukan konstruksi yang akan berhenti pada kesalahan pertama daripada harus menambahkan pendengar kesalahan untuk setiap kesalahan yang mungkin terjadi. (Jika Anda pernah menulis tiga panggilan balik bersarang di node.js, Anda tahu apa yang saya maksud.)
- Transparansi referensial : meskipun keunggulan ini umumnya dikaitkan dengan pemrograman fungsional, ini sebenarnya berlaku untuk pendekatan apa pun yang meminimalkan penanganan status secara manual dan bergantung pada efek samping.
- Komutatifitas : kemungkinan untuk menyatakan keadaan akhir tanpa harus menentukan urutan aktual di mana ia akan diimplementasikan.
Meskipun hal-hal di atas adalah keuntungan yang sering dikutip dari pemrograman deklaratif, saya ingin menyingkatnya menjadi dua kualitas, yang akan berfungsi sebagai prinsip panduan ketika saya mengusulkan pendekatan alternatif.
- Lapisan tingkat tinggi yang disesuaikan dengan domain tertentu : pemrograman deklaratif membuat lapisan tingkat tinggi menggunakan informasi domain tempat penerapannya. Jelas bahwa jika kita berurusan dengan database, kita menginginkan satu set operasi untuk menangani data. Sebagian besar dari tujuh keunggulan di atas berasal dari pembuatan lapisan tingkat tinggi yang secara tepat disesuaikan dengan domain masalah tertentu.
- Poka-yoke (fool-proofness) : lapisan tingkat tinggi yang disesuaikan dengan domain menyembunyikan detail imperatif implementasi. Ini berarti Anda melakukan kesalahan yang jauh lebih sedikit karena detail sistem tingkat rendah tidak dapat diakses. Batasan ini menghilangkan banyak kelas kesalahan dari kode Anda.
Dua Masalah Dengan Pemrograman Deklaratif
Dalam dua bagian berikut, saya akan menyajikan dua masalah utama pemrograman deklaratif: keterpisahan dan kurangnya pembukaan . Setiap kritik membutuhkan bogeyman-nya, jadi saya akan menggunakan sistem templating HTML sebagai contoh nyata dari kekurangan pemrograman deklaratif.
Masalah Dengan DSL: Keterpisahan
Bayangkan Anda perlu menulis aplikasi web dengan jumlah tampilan yang tidak sepele. Pengkodean keras tampilan ini ke dalam satu set file HTML bukanlah pilihan karena banyak komponen halaman ini berubah.
Solusi paling mudah, yaitu menghasilkan HTML dengan menggabungkan string, tampaknya sangat buruk sehingga Anda akan segera mencari alternatif. Solusi standar adalah dengan menggunakan sistem template. Meskipun ada berbagai jenis sistem templat, kami akan menghindari perbedaannya untuk tujuan analisis ini. Kita dapat menganggap semuanya serupa karena misi utama sistem templat adalah menyediakan alternatif untuk kode yang menggabungkan string HTML menggunakan kondisional dan loop, sama seperti RDB yang muncul sebagai alternatif kode yang dilingkarkan melalui catatan data.
Misalkan kita menggunakan sistem templating standar; Anda akan menemukan tiga sumber gesekan, yang akan saya sebutkan dalam urutan kepentingan. Yang pertama adalah bahwa template harus berada dalam file yang terpisah dari kode Anda. Karena sistem templating menggunakan DSL, maka sintaksnya berbeda, sehingga tidak bisa dalam satu file. Dalam proyek sederhana, di mana jumlah file rendah, kebutuhan untuk menyimpan file template terpisah dapat menduplikasi atau melipatgandakan jumlah file.
Saya membuka pengecualian untuk template Embedded Ruby (ERB), karena itu terintegrasi ke dalam kode sumber Ruby. Ini tidak berlaku untuk alat yang terinspirasi ERB yang ditulis dalam bahasa lain karena template tersebut juga harus disimpan sebagai file yang berbeda.
Sumber gesekan kedua adalah bahwa DSL memiliki sintaks sendiri, yang berbeda dari bahasa pemrograman Anda. Oleh karena itu, memodifikasi DSL (apalagi menulis sendiri) jauh lebih sulit. Untuk masuk ke dalam tenda dan mengubah alat, Anda perlu belajar tentang tokenizing dan parsing, yang menarik dan menantang, tetapi sulit. Saya kebetulan melihat ini sebagai kerugian.
Anda mungkin bertanya, “Mengapa Anda ingin memodifikasi alat Anda? Jika Anda melakukan proyek standar, alat standar yang ditulis dengan baik harus sesuai dengan tagihan.” Mungkin ya mungkin tidak.
DSL tidak pernah memiliki kekuatan penuh dari bahasa pemrograman. Jika ya, itu tidak akan menjadi DSL lagi, melainkan bahasa pemrograman lengkap.
Tapi bukankah itu inti dari DSL? Untuk tidak memiliki kekuatan penuh dari bahasa pemrograman yang tersedia, sehingga kita dapat mencapai abstraksi dan menghilangkan sebagian besar sumber bug? Mungkin ya. Namun, kebanyakan DSL dimulai dari yang sederhana dan kemudian secara bertahap menggabungkan fasilitas bahasa pemrograman yang jumlahnya terus bertambah hingga, pada kenyataannya, menjadi satu. Sistem template adalah contoh sempurna. Mari kita lihat fitur standar sistem template dan bagaimana mereka berkorelasi dengan fasilitas bahasa pemrograman:
- Ganti teks dalam template : substitusi variabel.
- Pengulangan template : loop.
- Hindari mencetak template jika kondisi tidak terpenuhi : conditional.
- Parsial : subrutin.
- Helpers : subrutin (satu-satunya perbedaan dengan sebagian adalah bahwa helper dapat mengakses bahasa pemrograman yang mendasarinya dan membiarkan Anda keluar dari straightjacket DSL).
Argumen ini, bahwa DSL terbatas karena secara bersamaan menginginkan dan menolak kekuatan bahasa pemrograman, berbanding lurus dengan sejauh mana fitur DSL secara langsung dapat dipetakan ke fitur bahasa pemrograman . Dalam kasus SQL, argumennya lemah karena sebagian besar hal yang ditawarkan SQL tidak seperti yang Anda temukan dalam bahasa pemrograman normal. Di ujung lain spektrum, kami menemukan sistem template di mana hampir setiap fitur membuat DSL menyatu menuju BASIC.
Sekarang mari kita mundur dan merenungkan tiga sumber gesekan yang klasik ini, yang disimpulkan oleh konsep keterpisahan . Karena terpisah, DSL perlu ditempatkan pada file terpisah; lebih sulit untuk memodifikasi (dan bahkan lebih sulit untuk menulis sendiri), dan (sering, tetapi tidak selalu) membutuhkan Anda untuk menambahkan, satu per satu, fitur yang Anda lewatkan dari bahasa pemrograman yang sebenarnya.
Keterpisahan adalah masalah yang melekat pada setiap DSL, tidak peduli seberapa baik dirancang.
Kami sekarang beralih ke masalah kedua alat deklaratif, yang tersebar luas tetapi tidak melekat.
Masalah Lain: Kurangnya Pembukaan Menyebabkan Kompleksitas
Jika saya menulis artikel ini beberapa bulan yang lalu, bagian ini akan diberi nama Alat Deklaratif Paling Banyak #@!$#@! Kompleks Tapi Saya Tidak Tahu Mengapa . Dalam proses penulisan artikel ini saya menemukan cara yang lebih baik untuk meletakkannya: Kebanyakan Alat Deklaratif Jauh Lebih Kompleks Dari Yang Seharusnya . Saya akan menghabiskan sisa bagian ini untuk menjelaskan alasannya. Untuk menganalisis kompleksitas alat, saya mengusulkan ukuran yang disebut kesenjangan kompleksitas . Kesenjangan kompleksitas adalah perbedaan antara memecahkan masalah yang diberikan dengan alat versus menyelesaikannya di tingkat yang lebih rendah (mungkin, kode imperatif biasa) yang ingin diganti oleh alat tersebut. Ketika solusi pertama lebih kompleks daripada yang terakhir, kita berada di hadapan kesenjangan kompleksitas. Dengan lebih kompleks , maksud saya lebih banyak baris kode, kode yang lebih sulit untuk dibaca, lebih sulit untuk dimodifikasi dan lebih sulit untuk dipelihara, tetapi tidak harus semuanya pada saat yang bersamaan.
Harap perhatikan bahwa kami tidak membandingkan solusi tingkat yang lebih rendah dengan alat terbaik yang mungkin, tetapi tidak dengan alat apa pun. Ini menggemakan prinsip medis "Pertama, jangan membahayakan" .
Tanda-tanda alat dengan kesenjangan kompleksitas yang besar adalah:
- Sesuatu yang membutuhkan beberapa menit untuk dijelaskan dengan sangat detail dalam istilah imperatif akan memakan waktu berjam-jam untuk dikodekan menggunakan alat ini, bahkan ketika Anda tahu cara menggunakan alat tersebut.
- Anda merasa Anda terus-menerus bekerja di sekitar alat daripada dengan alat.
- Anda berjuang untuk memecahkan masalah langsung yang termasuk dalam domain alat yang Anda gunakan, tetapi jawaban Stack Overflow terbaik yang Anda temukan menjelaskan solusi .
- Ketika masalah yang sangat sederhana ini dapat diselesaikan dengan fitur tertentu (yang tidak ada di alat) dan Anda melihat masalah Github di perpustakaan yang menampilkan diskusi panjang tentang fitur tersebut dengan +1 s diselingi.
- Kerinduan kronis, gatal, untuk membuang alat dan melakukan semuanya sendiri di dalam _ for- loop_.
Saya mungkin telah menjadi mangsa emosi di sini karena sistem templat tidak terlalu rumit, tetapi kesenjangan kompleksitas yang relatif kecil ini bukanlah kelebihan dari desain mereka, melainkan karena domain penerapannya cukup sederhana (ingat, kami hanya membuat HTML di sini ). Kapan pun pendekatan yang sama digunakan untuk domain yang lebih kompleks (seperti manajemen konfigurasi), kesenjangan kompleksitas dapat dengan cepat mengubah proyek Anda menjadi rawa.
Yang mengatakan, tidak selalu dapat diterima untuk suatu alat menjadi agak lebih kompleks daripada tingkat yang lebih rendah yang ingin diganti; jika alat tersebut menghasilkan kode yang lebih mudah dibaca, ringkas dan benar, itu bisa sangat berharga t. Ini adalah masalah ketika alat tersebut beberapa kali lebih kompleks daripada masalah yang digantikannya; ini benar-benar tidak dapat diterima. Brian Kernighan dengan terkenal menyatakan bahwa, “ Mengendalikan kompleksitas adalah inti dari pemrograman komputer. ” Jika sebuah alat menambah kompleksitas yang signifikan pada proyek Anda, mengapa menggunakannya?
Pertanyaannya adalah, mengapa beberapa alat deklaratif jauh lebih kompleks daripada yang dibutuhkan? Saya pikir itu akan menjadi kesalahan untuk menyalahkan desain yang buruk. Penjelasan umum seperti itu, serangan ad-hominem menyeluruh pada pembuat alat ini, tidak adil. Harus ada penjelasan yang lebih akurat dan mencerahkan.
Pendapat saya adalah bahwa alat apa pun yang menawarkan antarmuka tingkat tinggi untuk mengabstraksi tingkat yang lebih rendah harus membuka tingkat yang lebih tinggi ini dari yang lebih rendah. Konsep pembukaan berasal dari magnum opus Christopher Alexander, The Nature of Order - khususnya Volume II. Ini (tidak ada harapan) di luar cakupan artikel ini (belum lagi pemahaman saya) untuk meringkas implikasi dari karya monumental ini untuk desain perangkat lunak; Saya percaya dampaknya akan besar di tahun-tahun mendatang. Juga di luar artikel ini untuk memberikan definisi yang ketat tentang proses yang sedang berlangsung. Saya akan menggunakan konsep di sini dengan cara heuristik.
Proses yang berlangsung adalah proses yang, secara bertahap, menciptakan struktur lebih lanjut tanpa meniadakan yang sudah ada. Pada setiap langkah, setiap perubahan (atau diferensiasi, menggunakan istilah Alexander) tetap selaras dengan struktur sebelumnya, ketika struktur sebelumnya, secara sederhana, merupakan rangkaian kristalisasi dari perubahan masa lalu.
Cukup menarik, Unix adalah contoh yang bagus dari pembukaan level yang lebih tinggi dari yang lebih rendah. Di Unix, dua fitur kompleks dari sistem operasi, pekerjaan batch dan coroutine (pipa), hanyalah ekstensi dari perintah dasar. Karena keputusan desain mendasar tertentu, seperti membuat semuanya menjadi aliran byte, shell menjadi program userland dan file I/O standar, Unix mampu menyediakan fitur-fitur canggih ini dengan kompleksitas minimal.
Untuk menggarisbawahi mengapa ini adalah contoh yang sangat baik dari pembukaan, saya ingin mengutip beberapa kutipan dari makalah 1979 oleh Dennis Ritchie, salah satu penulis Unix:
Pada pekerjaan batch :
… skema kontrol proses baru secara instan membuat beberapa fitur yang sangat berharga menjadi sepele untuk diterapkan; misalnya proses terpisah (dengan
&
) dan penggunaan shell secara rekursif sebagai perintah. Sebagian besar sistem harus menyediakan semacam fasilitasbatch job submission
khusus dan penerjemah perintah khusus untuk file yang berbeda dari yang digunakan secara interaktif.
Pada coroutine :
Kejeniusan pipa Unix justru dibangun dari perintah yang sama yang digunakan terus-menerus dalam mode simpleks.
Keanggunan dan kesederhanaan ini, menurut saya, berasal dari proses yang berlangsung . Pekerjaan batch dan coroutine dibuka dari struktur sebelumnya (perintah dijalankan di shell userland). Saya percaya bahwa karena filosofi minimalis dan sumber daya yang terbatas dari tim yang menciptakan Unix, sistem berkembang secara bertahap, dan dengan demikian, dapat menggabungkan fitur-fitur canggih tanpa harus kembali ke yang dasar karena tidak ada cukup sumber daya untuk lakukan sebaliknya.
Dengan tidak adanya proses yang berlangsung, tingkat tinggi akan jauh lebih kompleks daripada yang diperlukan. Dengan kata lain, kompleksitas sebagian besar alat deklaratif berasal dari fakta bahwa tingkat tinggi mereka tidak terungkap dari tingkat rendah yang ingin mereka ganti.
Kurangnya pengungkapan , jika Anda memaafkan neologisme, secara rutin dibenarkan oleh kebutuhan untuk melindungi pengguna dari tingkat yang lebih rendah. Penekanan pada poka-yoke (melindungi pengguna dari kesalahan tingkat rendah) datang dengan mengorbankan kesenjangan kompleksitas besar yang merugikan diri sendiri karena kompleksitas tambahan akan menghasilkan kelas kesalahan baru. Untuk menambah penghinaan pada cedera, kelas kesalahan ini tidak ada hubungannya dengan domain masalah melainkan dengan alat itu sendiri. Kami tidak akan melangkah terlalu jauh jika kami menggambarkan kesalahan ini sebagai iatrogenik.
Alat templating deklaratif, setidaknya ketika diterapkan pada tugas menghasilkan tampilan HTML, adalah kasus pola dasar dari tingkat tinggi yang membelakangi tingkat rendah yang ingin diganti. Bagaimana? Karena menghasilkan tampilan non-sepele membutuhkan logika , dan sistem templating, terutama yang tanpa logika, membuang logika melalui pintu utama dan kemudian menyelundupkannya kembali melalui pintu kucing.
Catatan: Pembenaran yang lebih lemah untuk kesenjangan kompleksitas yang besar adalah ketika alat dipasarkan sebagai sulap , atau sesuatu yang hanya berfungsi , ketidakjelasan tingkat rendah seharusnya menjadi aset karena alat sulap selalu seharusnya bekerja tanpa Anda mengerti mengapa atau bagaimana. Dalam pengalaman saya, semakin ajaib suatu alat, semakin cepat ia mengubah antusiasme saya menjadi frustrasi.
Tapi bagaimana dengan pemisahan kekhawatiran? Bukankah seharusnya pandangan dan logika tetap terpisah? Kesalahan inti, di sini, adalah menempatkan logika bisnis dan logika presentasi di tas yang sama. Logika bisnis tentu tidak memiliki tempat dalam template, tetapi logika presentasi tetap ada. Mengecualikan logika dari template mendorong logika presentasi ke server di mana ia ditampung dengan canggung. Saya berhutang rumusan yang jelas tentang hal ini kepada Alexei Boronine, yang membuat kasus yang sangat baik untuk itu dalam artikel ini.
Perasaan saya adalah bahwa kira-kira dua pertiga dari pekerjaan templat berada dalam logika presentasinya, sementara sepertiga lainnya berurusan dengan masalah umum seperti merangkai string, tag penutup, mengeluarkan karakter khusus, dan sebagainya. Ini adalah sifat tingkat rendah bermuka dua dalam menghasilkan tampilan HTML. Sistem templating menangani babak kedua dengan tepat, tetapi tidak berjalan dengan baik dengan yang pertama. Template tanpa logika benar-benar mengabaikan masalah ini, memaksa Anda untuk menyelesaikannya dengan canggung. Sistem template lain menderita karena mereka benar-benar perlu menyediakan bahasa pemrograman non-sepele sehingga pengguna mereka benar-benar dapat menulis logika presentasi.
Untuk menyimpulkan; alat templating deklaratif menderita karena:
- Jika mereka terungkap dari domain masalah mereka, mereka harus menyediakan cara untuk menghasilkan pola logis;
- DSL yang menyediakan logika sebenarnya bukan DSL, tetapi bahasa pemrograman. Perhatikan bahwa domain lain, seperti manajemen konfigurasi, juga mengalami kekurangan "pembukaan".
Saya ingin menutup kritik dengan argumen yang secara logis terputus dari utas artikel ini, tetapi sangat bergema dengan inti emosionalnya: Kami memiliki waktu terbatas untuk belajar. Hidup ini singkat, dan di atas semua itu, kita perlu bekerja. Dalam menghadapi keterbatasan kita, kita perlu meluangkan waktu untuk mempelajari hal-hal yang berguna dan tahan terhadap waktu, bahkan dalam menghadapi teknologi yang berubah dengan cepat. Itulah mengapa saya mendorong Anda untuk menggunakan alat yang tidak hanya memberikan solusi tetapi juga memberikan titik terang pada domain penerapannya sendiri. RDB mengajari Anda tentang data, dan Unix mengajari Anda tentang konsep OS, tetapi dengan alat yang tidak memuaskan yang tidak terungkap, saya selalu merasa mempelajari seluk-beluk solusi sub-optimal sambil tetap tidak mengetahui sifat masalah itu bermaksud untuk memecahkan.

Heuristik yang saya sarankan untuk Anda pertimbangkan adalah, alat nilai yang menerangi domain masalah mereka, alih-alih alat yang mengaburkan domain masalah mereka di balik fitur yang diklaim .
Pendekatan Kembar
Untuk mengatasi dua masalah pemrograman deklaratif, yang telah saya sajikan di sini, saya mengusulkan pendekatan kembar:
- Gunakan bahasa khusus domain struktur data (dsDSL), untuk mengatasi keterpisahan.
- Buat tingkat tinggi yang terbentang dari tingkat yang lebih rendah, untuk mengatasi kesenjangan kompleksitas.
dsDSL
Struktur data DSL (dsDSL) adalah DSL yang dibangun dengan struktur data bahasa pemrograman . Ide intinya adalah menggunakan struktur data dasar yang Anda miliki, seperti string, angka, array, objek, dan fungsi, dan menggabungkannya untuk membuat abstraksi untuk menangani domain tertentu.
Kami ingin mempertahankan kekuatan mendeklarasikan struktur atau tindakan (tingkat tinggi) tanpa harus menentukan pola yang mengimplementasikan konstruksi ini (tingkat rendah). Kami ingin mengatasi keterpisahan antara DSL dan bahasa pemrograman kami sehingga kami bebas menggunakan kekuatan penuh bahasa pemrograman kapan pun kami membutuhkannya. Ini tidak hanya mungkin tetapi langsung melalui dsDSL.
Jika Anda bertanya kepada saya setahun yang lalu, saya akan berpikir bahwa konsep dsDSL adalah novel, kemudian suatu hari, saya menyadari bahwa JSON sendiri adalah contoh sempurna dari pendekatan ini! Objek JSON yang diurai terdiri dari struktur data yang secara deklaratif mewakili entri data untuk mendapatkan keuntungan dari DSL sementara juga memudahkan penguraian dan penanganan dari dalam bahasa pemrograman. (Mungkin ada dsDSL lain di luar sana, tapi sejauh ini saya belum menemukannya. Jika Anda mengetahuinya, saya akan sangat menghargai Anda menyebutkannya di bagian komentar.)
Seperti JSON, dsDSL memiliki atribut berikut:
- Ini terdiri dari serangkaian fungsi yang sangat kecil: JSON memiliki dua fungsi utama,
parse
danstringify
. - Fungsinya paling sering menerima argumen yang kompleks dan rekursif: JSON yang diurai adalah array, atau objek, yang biasanya berisi array dan objek lebih lanjut di dalamnya.
- Input ke fungsi-fungsi ini sesuai dengan bentuk yang sangat spesifik: JSON memiliki skema validasi yang tegas dan tegas untuk membedakan struktur yang valid dari yang tidak valid.
- Baik input maupun output dari fungsi-fungsi ini dapat ditampung dan dihasilkan oleh bahasa pemrograman tanpa sintaks terpisah.
Tetapi dsDSL melampaui JSON dalam banyak hal. Mari kita buat dsDSL untuk menghasilkan HTML menggunakan Javascript. Nanti saya akan menyentuh masalah apakah pendekatan ini dapat diperluas ke bahasa lain (spoiler: Ini pasti bisa dilakukan di Ruby dan Python, tapi mungkin tidak di C).
HTML adalah bahasa markup yang terdiri dari tags
dibatasi oleh tanda kurung siku ( <
dan >
). Tag ini mungkin memiliki atribut dan konten opsional. Atribut hanyalah daftar atribut kunci/nilai, dan isinya dapat berupa teks atau tag lainnya. Atribut dan konten adalah opsional untuk setiap tag yang diberikan. Saya agak menyederhanakan, tetapi ini akurat.
Cara mudah untuk merepresentasikan tag HTML dalam dsDSL adalah dengan menggunakan array dengan tiga elemen: - Tag: string. - Atribut: sebuah objek (dari tipe biasa, kunci/nilai) atau undefined
(jika tidak ada atribut yang diperlukan). - Isi: string (teks), larik (tag lain) atau undefined
(jika tidak ada konten).
Misalnya, <a href="views">Index</a>
dapat ditulis sebagai ['a', {href: 'views'}, 'Index']
.
Jika kita ingin menyematkan elemen jangkar ini ke dalam div
dengan links
kelas , kita dapat menulis: ['div', {class: 'links'}, ['a', {href: 'views'}, 'Index']]
.
Untuk membuat daftar beberapa tag html pada level yang sama, kita dapat membungkusnya dalam sebuah array:
[ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]
Prinsip yang sama dapat diterapkan untuk membuat beberapa tag dalam satu tag:
['body', [ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]]
Tentu saja, dsDSL ini tidak akan membawa kita jauh jika kita tidak menghasilkan HTML darinya. Kami membutuhkan fungsi generate
yang akan mengambil dsDSL kami dan menghasilkan string dengan HTML. Jadi jika kita menjalankan generate (['a', {href: 'views'}, 'Index'])
, kita akan mendapatkan string <a href="views">Index</a>
.
Ide di balik setiap DSL adalah untuk menentukan beberapa konstruksi dengan struktur tertentu yang kemudian diteruskan ke suatu fungsi. Dalam hal ini, struktur yang membentuk dsDSL adalah larik ini, yang memiliki satu hingga tiga elemen; array ini memiliki struktur tertentu. Jika generate
benar-benar memvalidasi inputnya (dan sangat mudah dan penting untuk memvalidasi input secara menyeluruh, karena aturan validasi ini adalah analog yang tepat dari sintaks DSL), ini akan memberi tahu Anda dengan tepat di mana Anda salah dengan input Anda. Setelah beberapa saat, Anda akan mulai mengenali apa yang membedakan struktur valid dalam dsDSL, dan struktur ini akan sangat menunjukkan hal mendasar yang dihasilkannya.
Sekarang, apa manfaat dsDSL yang bertentangan dengan DSL?
- dsDSL adalah bagian integral dari kode Anda. Ini mengarah ke jumlah baris yang lebih rendah, jumlah file, dan pengurangan overhead secara keseluruhan.
- dsDSL mudah diurai (sehingga lebih mudah diimplementasikan dan dimodifikasi). Parsing hanyalah iterasi melalui elemen array atau objek. Demikian juga, dsDSL relatif mudah untuk dirancang karena alih-alih membuat sintaks baru (yang akan dibenci semua orang), Anda dapat tetap menggunakan sintaks bahasa pemrograman Anda (yang dibenci semua orang tetapi setidaknya mereka sudah mengetahuinya).
- Sebuah dsDSL memiliki semua kekuatan bahasa pemrograman. Ini berarti bahwa dsDSL, bila digunakan dengan benar, memiliki keuntungan dari alat tingkat tinggi dan rendah.
Sekarang, klaim terakhir adalah klaim yang kuat, jadi saya akan menggunakan sisa bagian ini untuk mendukungnya. Apa yang saya maksud dengan dipekerjakan dengan benar ? Untuk melihat tindakan ini, mari pertimbangkan contoh di mana kita ingin membuat tabel untuk menampilkan informasi dari array bernama DATA
.
var DATA = [ {id: 1, description: 'Product 1', price: 20, onSale: true, categories: ['a']}, {id: 2, description: 'Product 2', price: 60, onSale: false, categories: ['b']}, {id: 3, description: 'Product 3', price: 120, onSale: false, categories: ['a', 'c']}, {id: 4, description: 'Product 4', price: 45, onSale: true, categories: ['a', 'b']} ]
Dalam aplikasi nyata, DATA
akan dihasilkan secara dinamis dari kueri basis data.
Selain itu, kami memiliki variabel FILTER
yang, ketika diinisialisasi, akan menjadi array dengan kategori yang ingin kami tampilkan.
Kami ingin meja kami:
- Menampilkan header tabel.
- Untuk setiap produk, tunjukkan bidang: deskripsi, harga, dan kategori.
- Jangan cetak bidang
id
, tetapi tambahkan sebagai atributid
untuk setiap baris. VERSI ALTERNATIF: Tambahkan atributid
ke setiap elementr
. - Tempatkan kelas
onSale
jika produk sedang dijual. - Urutkan produk berdasarkan harga turun.
- Filter produk tertentu berdasarkan kategori. Jika
FILTER
adalah array kosong, kami akan menampilkan semua produk. Jika tidak, kami hanya akan menampilkan produk yang kategori produknya terdapat dalamFILTER
.
Kita dapat membuat logika presentasi yang cocok dengan persyaratan ini dalam ~20 baris kode:
function drawTable (DATA, FILTER) { var printableFields = ['description', 'price', 'categories']; DATA.sort (function (a, b) {return a.price - b.price}); return ['table', [ ['tr', dale.do (printableFields, function (field) { return ['th', field]; })], dale.do (DATA, function (product) { var matches = (! FILTER || FILTER.length === 0) || dale.stop (product.categories, true, function (category) { return FILTER.indexOf (category) !== -1; }); return matches === false ? [] : ['tr', { id: product.id, class: product.onSale ? 'onsale' : undefined }, dale.do (printableFields, function (field) { return ['td', product [field]]; })]; }) ]]; }
Saya akui ini bukan contoh langsung, namun, ini mewakili pandangan yang cukup sederhana dari empat fungsi dasar penyimpanan persisten, juga dikenal sebagai CRUD. Aplikasi web non-sepele apa pun akan memiliki tampilan yang lebih kompleks dari ini.
Sekarang mari kita lihat apa yang dilakukan kode ini. Pertama, ia mendefinisikan sebuah fungsi, drawTable
, untuk memuat logika presentasi menggambar tabel produk. Fungsi ini menerima DATA
dan FILTER
sebagai parameter, sehingga dapat digunakan untuk kumpulan data dan filter yang berbeda. drawTable
memenuhi peran ganda parsial dan pembantu.
var drawTable = function (DATA, FILTER) {
Variabel dalam, printableFields
, adalah satu-satunya tempat di mana Anda perlu menentukan bidang mana yang dapat dicetak, menghindari pengulangan dan inkonsistensi dalam menghadapi perubahan persyaratan.
var printableFields = ['description', 'price', 'categories'];
Kami kemudian mengurutkan DATA
menurut harga produknya. Perhatikan bahwa kriteria pengurutan yang berbeda dan lebih kompleks akan mudah diterapkan karena kami memiliki seluruh bahasa pemrograman yang kami miliki.
DATA.sort (function (a, b) {return a.price - b.price});
Di sini kita mengembalikan objek literal; array yang berisi table
sebagai elemen pertama dan isinya sebagai elemen kedua. Ini adalah representasi dsDSL dari <table>
yang ingin kita buat.
return ['table', [
Kami sekarang membuat baris dengan header tabel. Untuk membuat isinya, kami menggunakan dale.do yang merupakan fungsi seperti Array.map, tetapi juga berfungsi untuk objek. Kami akan mengulangi printableFields
dan menghasilkan header tabel untuk masing-masingnya:
['tr', dale.do (printableFields, function (field) { return ['th', field]; })],
Perhatikan bahwa kami baru saja mengimplementasikan iterasi, pekerja keras dari pembuatan HTML, dan kami tidak memerlukan konstruksi DSL; kami hanya membutuhkan fungsi untuk mengulangi struktur data dan mengembalikan dsDSL. Fungsi asli yang serupa, atau yang diimplementasikan pengguna, akan berhasil juga.
Sekarang ulangi melalui produk yang terkandung dalam DATA
.
dale.do (DATA, function (product) {
Kami memeriksa apakah produk ini ditinggalkan oleh FILTER
. Jika FILTER
kosong, kami akan mencetak produk. Jika FILTER
tidak kosong, kami akan mengulangi kategori produk sampai kami menemukan yang ada di dalam FILTER
. Kami melakukan ini menggunakan dale.stop.
var matches = (! FILTER || FILTER.length === 0) || dale.stop (product.categories, true, function (category) { return FILTER.indexOf (category) !== -1; });
Perhatikan kerumitan kondisional; itu tepat disesuaikan dengan kebutuhan kami dan kami memiliki kebebasan total untuk mengekspresikannya karena kami menggunakan bahasa pemrograman daripada DSL.
Jika matches
adalah false
, kami mengembalikan array kosong (jadi kami tidak mencetak produk ini). Jika tidak, kami mengembalikan <tr>
dengan id dan kelas yang tepat dan kami mengulangi melalui printableFields
untuk, yah, mencetak bidang.
return matches === false ? [] : ['tr', { id: product.id, class: product.onSale ? 'onsale' : undefined }, dale.do (printableFields, function (field) { return ['td', product [field]];
Tentu saja kami menutup semua yang kami buka. Bukankah sintaks menyenangkan?
})]; }) ]]; }
Sekarang, bagaimana kita memasukkan tabel ini ke dalam konteks yang lebih luas? Kami menulis fungsi bernama drawAll
yang akan memanggil semua fungsi yang menghasilkan tampilan. Selain drawTable
, kita mungkin juga memiliki drawHeader
, drawFooter
dan fungsi lain yang sebanding, yang semuanya akan mengembalikan dsDSLs .
var drawAll = function () { return generate ([ drawHeader (), drawTable (DATA, FILTER), drawFooter () ]); }
Jika Anda tidak menyukai tampilan kode di atas, tidak ada yang saya katakan akan meyakinkan Anda. Ini adalah dsDSL yang terbaik . Sebaiknya Anda berhenti membaca artikel tersebut (dan memberikan komentar jahat juga karena Anda berhak melakukannya jika Anda telah sampai sejauh ini!). But seriously, if the code above doesn't strike you as elegant, nothing else in this article will.
For those who are still with me, I would like to go back to the main claim of this section, which is that a dsDSL has the advantages of both the high and the low level :
- The advantage of the low level resides in writing code whenever we want, getting out of the straightjacket of the DSL.
- The advantage of the high level resides in using literals that represent what we want to declare and letting the functions of the tool convert that into the desired end state (in this case, a string with HTML).
But how is this truly different from purely imperative code? I think ultimately the elegance of the dsDSL approach boils down to the fact that code written in this way mostly consists of expressions, instead of statements. More precisely, code that uses a dsDSL is almost entirely composed of:
- Literals that map to lower level structures.
- Function invocations or lambdas within those literal structures that return structures of the same kind.
Code that consists mostly of expressions and which encapsulate most statements within functions is extremely succinct because all patterns of repetition can be easily abstracted. You can write arbitrary code as long as that code returns a literal that conforms to a very specific, non-arbitrary form.
A further characteristic of dsDSLs (which we don't have time to explore here) is the possibility of using types to increase the richness and succinctness of the literal structures. I will expound on this issue on a future article.
Might it be possible to create dsDSLs beyond Javascript, the One True Language? I think that it is, indeed, possible, as long as the language supports:
- Literals for: arrays, objects (associative arrays), function invocations, and lambdas.
- Runtime type detection
- Polymorphism and dynamic return types
I think this means that dsDSLs are tenable in any modern dynamic language (ie: Ruby, Python, Perl, PHP), but probably not in C or Java.
Walk, Then Slide: How To Unfold The High From The Low
In this section I will attempt to show a way for unfolding a high level tool from its domain. In a nutshell, the approach consists of the following steps
- Take two to four problems that are representative instances of a problem domain. These problems should be real. Unfolding the high level from the low one is a problem of induction, so you need real data to come up with representative solutions.
- Solve the problems with no tool in the most straightforward way possible.
- Stand back, take a good look at your solutions, and notice the common patterns among them.
- Find the patterns of representation (high level).
- Find the patterns of generation (low level).
- Solve the same problems with your high level layer and verify that the solutions are indeed correct.
- If you feel that you can easily represent all the problems with your patterns of representation, and the generation patterns for each of these instances produce correct implementations, you're done. Otherwise, go back to the drawing board.
- If new problems appear, solve them with the tool and modify it accordingly.
- The tool should converge asymptotically to a finished state, no matter how many problems it solves. In other words, the complexity of the tool should remain constant, rather than growing with the amount of problems it solves.
Now, what the hell are patterns of representation and patterns of generation ? I'm glad you asked. The patterns of representation are the patterns in which you should be able to express a problem that belongs to the domain that concerns your tool. It is an alphabet of structures that allows you to write any pattern you might wish to express within its domain of applicability. In a DSL, these would be the production rules. Let's go back to our dsDSL for generating HTML.
The patterns of representation for HTML are the following:
- A single tag:
['TAG']
- A single tag with attributes:
['TAG', {attribute1: value1, attribute2: value2, ...}]
- A single tag with contents:
['TAG', 'CONTENTS']
- A single tag with both attributes and contents:
['TAG', {attribute1: value1, ...}, 'CONTENTS']
- A single tag with another tag inside:
['TAG1', ['TAG2', ...]]
- A group of tags (standalone or inside another tag):
[['TAG1', ...], ['TAG2', ...]]
- Depending on a condition, place a tag or no tag:
condition ? ['TAG', ...] : []
/ Depending on a condition, place an attribute or no attribute:['TAG', {class: condition ? 'someClass': undefined}, ...]
These instances can be represented with the dsDSL notation we determined in the previous section. And this is all you need to represent any HTML you might need. More sophisticated patterns, such as conditional iteration through an object to generate a table, may be implemented with functions that return the patterns of representation above, and these patterns map directly to HTML tags.
If the patterns of representation are the structures you use to express what you want, the patterns of generation are the structures your tool will use to convert patterns of representation into the lower level structures. For HTML, these are the following:
- Validate the input (this is actually is an universal pattern of generation).
- Open and close tags (but not the void tags, like
<input>
, which are self-closing). - Place attributes and contents, escaping special characters (but not the contents of the
<style>
and<script>
tags).
Believe it or not, these are the patterns you need to create an unfolding dsDSL layer that generates HTML. Similar patterns can be found for generating CSS. In fact, lith does both, in ~250 lines of code.
One last question remains to be answered: What do I mean by walk, then slide ? When we deal with a problem domain, we want to use a tool that delivers us from the nasty details of that domain. In other words, we want to sweep the low level under the rug, the faster the better. The walk, then slide approach proposes exactly the opposite: spend some time on the low level. Embrace its quirks, and understand which are essential and which can be avoided in the face of a set of real, varied, and useful problems.
After walking in the low level for some time and solving useful problems, you will have a sufficiently deep understanding of their domain. The patterns of representation and generation will then arise naturally; they are wholly derived from the nature of the problem they intend to solve. You can then write code that employs them. If they work, you will be able to slide through problems where you recently had to walk through them. Sliding means many things; it implies speed, precision and lack of friction. Maybe more importantly, this quality can be felt; when solving problems with this tool, do you feel like you're walking through the problem, or do you feel that you're sliding through it?
Maybe the most important thing about an unfolded tool is not the fact that it frees us from having to deal with the low level. Rather, by capturing the empiric patterns of repetition in the low level, a good high level tool allows us to understand fully the domain of applicability.
An unfolded tool will not just solve a problem - it will enlighten you about the problem's structure.
So, don't run away from a worthy problem. First walk around it, then slide through it.