Membuat Ruby DSL: Panduan untuk Pemrograman Meta Tingkat Lanjut
Diterbitkan: 2022-03-11Bahasa khusus domain (DSL) adalah alat yang sangat kuat untuk mempermudah memprogram atau mengonfigurasi sistem yang kompleks. Mereka juga ada di mana-mana—sebagai insinyur perangkat lunak, Anda kemungkinan besar menggunakan beberapa DSL berbeda setiap hari.
Dalam artikel ini, Anda akan mempelajari apa itu bahasa khusus domain, kapan harus digunakan, dan terakhir bagaimana Anda dapat membuat DSL Anda sendiri di Ruby menggunakan teknik metaprogramming tingkat lanjut.
Artikel ini dibangun di atas pengantar Nikola Todorovic ke Ruby metaprogramming, juga diterbitkan di Toptal Blog. Jadi jika Anda baru mengenal metaprogramming, pastikan Anda membacanya terlebih dahulu.
Apa itu Bahasa Khusus Domain?
Definisi umum DSL adalah bahwa mereka adalah bahasa yang dikhususkan untuk domain aplikasi atau kasus penggunaan tertentu. Ini berarti bahwa Anda hanya dapat menggunakannya untuk hal-hal tertentu—mereka tidak cocok untuk pengembangan perangkat lunak tujuan umum. Jika kedengarannya luas, itu karena itu—DSL tersedia dalam berbagai bentuk dan ukuran. Berikut adalah beberapa kategori penting:
- Bahasa markup seperti HTML dan CSS dirancang untuk menjelaskan hal-hal tertentu seperti struktur, konten, dan gaya halaman web. Tidak mungkin untuk menulis algoritma arbitrer dengan mereka, sehingga mereka cocok dengan deskripsi DSL.
- Makro dan bahasa kueri (misalnya, SQL) berada di atas sistem tertentu atau bahasa pemrograman lain dan biasanya terbatas pada apa yang dapat mereka lakukan. Oleh karena itu mereka jelas memenuhi syarat sebagai bahasa khusus domain.
- Banyak DSL tidak memiliki sintaksnya sendiri—sebaliknya, mereka menggunakan sintaks bahasa pemrograman yang mapan dengan cara yang cerdas yang terasa seperti menggunakan bahasa mini yang terpisah.
Kategori terakhir ini disebut DSL internal , dan ini adalah salah satunya yang akan kita buat sebagai contoh segera. Namun sebelum kita membahasnya, mari kita lihat beberapa contoh DSL internal yang terkenal. Sintaks definisi rute di Rails adalah salah satunya:
Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end
Ini adalah kode Ruby, namun rasanya lebih seperti bahasa definisi rute khusus, berkat berbagai teknik metaprogramming yang memungkinkan antarmuka yang bersih dan mudah digunakan. Perhatikan bahwa struktur DSL diimplementasikan menggunakan blok Ruby, dan pemanggilan metode seperti get
dan resources
digunakan untuk mendefinisikan kata kunci dari bahasa mini ini.
Metaprogramming digunakan lebih banyak lagi di library pengujian RSpec:
describe UsersController, type: :controller do before do allow(controller).to receive(:current_user).and_return(nil) end describe "GET #new" do subject { get :new } it "returns success" do expect(subject).to be_success end end end
Potongan kode ini juga berisi contoh untuk antarmuka yang lancar , yang memungkinkan deklarasi dibacakan sebagai kalimat bahasa Inggris biasa, membuatnya lebih mudah untuk memahami apa yang dilakukan kode:
# Stubs the `current_user` method on `controller` to always return `nil` allow(controller).to receive(:current_user).and_return(nil) # Asserts that `subject.success?` is truthy expect(subject).to be_success
Contoh lain dari antarmuka yang lancar adalah antarmuka kueri ActiveRecord dan Arel, yang menggunakan pohon sintaksis abstrak secara internal untuk membangun kueri SQL yang kompleks:
Post. # => select([ # SELECT Post[Arel.star], # `posts`.*, Comment[:id].count. # COUNT(`comments`.`id`) as("num_comments"), # AS num_comments ]). # FROM `posts` joins(:comments). # INNER JOIN `comments` # ON `comments`.`post_id` = `posts`.`id` where.not(status: :draft). # WHERE `posts`.`status` <> 'draft' where( # AND Post[:created_at].lte(Time.now) # `posts`.`created_at` <= ). # '2017-07-01 14:52:30' group(Post[:id]) # GROUP BY `posts`.`id`
Meskipun sintaks Ruby yang bersih dan ekspresif bersama dengan kemampuan metaprogrammingnya membuatnya secara unik cocok untuk membangun bahasa khusus domain, DSL juga ada dalam bahasa lain. Berikut adalah contoh pengujian JavaScript menggunakan framework Jasmine:
describe("Helper functions", function() { beforeEach(function() { this.helpers = window.helpers; }); describe("log error", function() { it("logs error message to console", function() { spyOn(console, "log").and.returnValue(true); this.helpers.log_error("oops!"); expect(console.log).toHaveBeenCalledWith("ERROR: oops!"); }); }); });
Sintaks ini mungkin tidak sebersih contoh Ruby, tetapi ini menunjukkan bahwa dengan penamaan yang cerdas dan penggunaan sintaks yang kreatif, DSL internal dapat dibuat menggunakan hampir semua bahasa.
Manfaat DSL internal adalah mereka tidak memerlukan pengurai terpisah, yang bisa jadi sangat sulit untuk diterapkan dengan benar. Dan karena mereka menggunakan sintaks bahasa tempat mereka diimplementasikan, mereka juga berintegrasi mulus dengan basis kode lainnya.
Apa yang harus kita korbankan sebagai gantinya adalah kebebasan sintaksis—DSL internal harus valid secara sintaksis dalam bahasa implementasinya. Seberapa banyak Anda harus berkompromi dalam hal ini sangat tergantung pada bahasa yang dipilih, dengan verbose, bahasa yang diketik secara statis seperti Java dan VB.NET berada di salah satu ujung spektrum, dan bahasa dinamis dengan kemampuan metaprogramming yang luas seperti Ruby di sisi lain akhir.
Membangun Sendiri—DSL Ruby untuk Konfigurasi Kelas
Contoh DSL yang akan kita buat di Ruby adalah mesin konfigurasi yang dapat digunakan kembali untuk menentukan atribut konfigurasi kelas Ruby menggunakan sintaks yang sangat sederhana. Menambahkan kemampuan konfigurasi ke kelas adalah persyaratan yang sangat umum di dunia Ruby, terutama ketika harus mengonfigurasi permata eksternal dan klien API. Solusi yang biasa adalah antarmuka seperti ini:
MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" end
Mari kita implementasikan antarmuka ini terlebih dahulu—lalu, menggunakannya sebagai titik awal, kita dapat meningkatkannya langkah demi langkah dengan menambahkan lebih banyak fitur, membersihkan sintaks, dan membuat pekerjaan kita dapat digunakan kembali.
Apa yang kita butuhkan untuk membuat antarmuka ini berfungsi? Kelas MyApp
harus memiliki metode kelas configure
yang mengambil blok dan kemudian mengeksekusi blok itu dengan menyerahkannya, meneruskan objek konfigurasi yang memiliki metode pengakses untuk membaca dan menulis nilai konfigurasi:
class MyApp # ... class << self def config @config ||= Configuration.new end def configure yield config end end class Configuration attr_accessor :app_id, :title, :cookie_name end end
Setelah blok konfigurasi berjalan, kita dapat dengan mudah mengakses dan mengubah nilainya:
MyApp.config => #<MyApp::Configuration:0x2c6c5e0 @app_, @title="My App", @cookie_name="my_app_session"> MyApp.config.title => "My App" MyApp.config.app_ => "not_my_app"
Sejauh ini, implementasi ini tidak terasa seperti bahasa kustom yang cukup untuk dianggap sebagai DSL. Tapi mari kita lakukan langkah demi langkah. Selanjutnya, kita akan memisahkan fungsionalitas konfigurasi dari kelas MyApp
dan membuatnya cukup umum untuk dapat digunakan dalam banyak kasus penggunaan yang berbeda.
Membuatnya Dapat Digunakan Kembali
Saat ini, jika kita ingin menambahkan kemampuan konfigurasi yang serupa ke kelas yang berbeda, kita harus menyalin kelas Configuration
dan metode penyiapan terkaitnya ke dalam kelas lain tersebut, serta mengedit daftar attr_accessor
untuk mengubah atribut konfigurasi yang diterima. Untuk menghindari keharusan melakukan ini, mari pindahkan fitur konfigurasi ke modul terpisah bernama Configurable
. Dengan itu, kelas MyApp
kami akan terlihat seperti ini:
class MyApp #BOLD include Configurable #BOLDEND # ... end
Segala sesuatu yang terkait dengan konfigurasi telah dipindahkan ke modul yang Configurable
:
#BOLD module Configurable def self.included(host_class) host_class.extend ClassMethods end module ClassMethods #BOLDEND def config @config ||= Configuration.new end def configure yield config end #BOLD end #BOLDEND class Configuration attr_accessor :app_id, :title, :cookie_name end #BOLD end #BOLDEND
Tidak banyak yang berubah di sini, kecuali untuk metode self.included
yang baru. Kami memerlukan metode ini karena menyertakan modul hanya bercampur dalam metode instance-nya, jadi metode kelas config
dan configure
kami tidak akan ditambahkan ke kelas host secara default. Namun, jika kita mendefinisikan metode khusus yang disebut included
pada sebuah modul, Ruby akan memanggilnya setiap kali modul itu disertakan dalam sebuah kelas. Di sana kita dapat secara manual memperluas kelas host dengan metode di ClassMethods
:
def self.included(host_class) # called when we include the module in `MyApp` host_class.extend ClassMethods # adds our class methods to `MyApp` end
Kita belum selesai—langkah selanjutnya adalah memungkinkan untuk menentukan atribut yang didukung di kelas host yang menyertakan modul yang Configurable
. Solusi seperti ini akan terlihat bagus:
class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end
Mungkin agak mengejutkan, kode di atas secara sintaksis benar— include
bukanlah kata kunci tetapi hanya metode biasa yang mengharapkan objek Module
sebagai parameternya. Selama kami memberikan ekspresi yang mengembalikan Module
, itu akan dengan senang hati memasukkannya. Jadi, alih-alih menyertakan Configurable
secara langsung, kita memerlukan metode with
nama di atasnya yang menghasilkan modul baru yang disesuaikan dengan atribut yang ditentukan:
module Configurable #BOLD def self.with(*attrs) #BOLDEND # Define anonymous class with the configuration attributes #BOLD config_class = Class.new do attr_accessor *attrs end #BOLDEND # Define anonymous module for the class methods to be "mixed in" #BOLD class_methods = Module.new do define_method :config do @config ||= config_class.new end #BOLDEND def configure yield config end #BOLD end #BOLDEND # Create and return new module #BOLD Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end #BOLDEND end
Ada banyak hal yang harus dibongkar di sini. Seluruh modul yang Configurable
sekarang hanya terdiri dari satu metode, with
semua yang terjadi di dalam metode itu. Pertama, kami membuat kelas anonim baru dengan Class.new
untuk menampung metode pengakses atribut kami. Karena Class.new
mengambil definisi kelas sebagai blok dan blok memiliki akses ke variabel luar, kami dapat meneruskan variabel attrs
ke attr_accessor
tanpa masalah.
def self.with(*attrs) # `attrs` is created here # ... config_class = Class.new do # class definition passed in as a block attr_accessor *attrs # we have access to `attrs` here end
Fakta bahwa blok di Ruby memiliki akses ke variabel luar juga merupakan alasan mengapa mereka kadang-kadang disebut penutupan , karena mereka menyertakan, atau "menutup" lingkungan luar tempat mereka didefinisikan. Perhatikan bahwa saya menggunakan frasa "didefinisikan di" dan tidak "dieksekusi di". Itu benar – terlepas dari kapan dan di mana blok define_method
kita akhirnya akan dieksekusi, mereka akan selalu dapat mengakses variabel config_class
dan class_methods
, bahkan setelah metode with
selesai dijalankan dan dikembalikan. Contoh berikut menunjukkan perilaku ini:
def create_block foo = "hello" # define local variable return Proc.new { foo } # return a new block that returns `foo` end block = create_block # call `create_block` to retrieve the block block.call # even though `create_block` has already returned, => "hello" # the block can still return `foo` to us
Sekarang setelah kita mengetahui tentang perilaku blok yang rapi ini, kita dapat melanjutkan dan mendefinisikan modul anonim di class_methods
untuk metode kelas yang akan ditambahkan ke kelas host saat modul yang kita buat disertakan. Di sini kita harus menggunakan define_method
untuk mendefinisikan metode config
, karena kita memerlukan akses ke variabel config_class
luar dari dalam metode. Mendefinisikan metode menggunakan kata kunci def
tidak akan memberi kita akses itu karena definisi metode biasa dengan def
bukanlah penutupan – namun, define_method
mengambil blok, jadi ini akan berhasil:

config_class = # ... # `config_class` is defined here # ... class_methods = Module.new do # define new module using a block define_method :config do # method definition with a block @config ||= config_class.new # even two blocks deep, we can still end # access `config_class`
Terakhir, kita panggil Module.new
untuk membuat modul yang akan kita kembalikan. Di sini kita perlu mendefinisikan metode self.included
kita, tetapi sayangnya kita tidak dapat melakukannya dengan kata kunci def
, karena metode tersebut memerlukan akses ke variabel class_methods
luar. Oleh karena itu, kita harus menggunakan define_method
dengan blok lagi, tetapi kali ini pada kelas tunggal modul, karena kita mendefinisikan metode pada instance modul itu sendiri. Oh, dan karena define_method
adalah metode privat dari kelas singleton, kita harus menggunakan send
untuk memanggilnya alih-alih memanggilnya secara langsung:
class_methods = # ... # ... Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods # the block has access to `class_methods` end end
Fiuh, itu sudah beberapa metaprogramming yang cukup hardcore. Tetapi apakah kompleksitas tambahan itu sepadan? Lihatlah betapa mudahnya menggunakannya dan putuskan sendiri:
class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat"
Tapi kami bisa lebih baik lagi. Pada langkah selanjutnya kita akan sedikit membersihkan sintaks dari blok configure
untuk membuat modul kita lebih nyaman digunakan.
Membersihkan Sintaks
Ada satu hal terakhir yang masih mengganggu saya dengan implementasi kita saat ini—kita harus mengulang config
pada setiap baris di blok konfigurasi. DSL yang tepat akan mengetahui bahwa semua yang ada di dalam blok configure
harus dieksekusi dalam konteks objek konfigurasi kita dan memungkinkan kita untuk mencapai hal yang sama hanya dengan ini:
MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end
Mari kita terapkan, ya? Dari kelihatannya, kita akan membutuhkan dua hal. Pertama, kita membutuhkan cara untuk mengeksekusi blok yang diteruskan untuk configure
dalam konteks objek konfigurasi sehingga panggilan metode di dalam blok menuju ke objek itu. Kedua, kita harus mengubah metode pengakses sehingga mereka menulis nilai jika argumen diberikan kepada mereka dan membacanya kembali saat dipanggil tanpa argumen. Kemungkinan implementasi terlihat seperti ini:
module Configurable def self.with(*attrs) #BOLD not_provided = Object.new #BOLDEND config_class = Class.new do #BOLD attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end attr_writer *attrs #BOLDEND end class_methods = Module.new do # ... def configure(&block) #BOLD config.instance_eval(&block) #BOLDEND end end # Create and return new module # ... end end
Perubahan yang lebih sederhana di sini adalah menjalankan blok configure
dalam konteks objek konfigurasi. Memanggil metode instance_eval
Ruby pada suatu objek memungkinkan Anda mengeksekusi blok kode arbitrer seolah-olah itu berjalan di dalam objek itu, yang berarti bahwa ketika blok konfigurasi memanggil metode app_id
pada baris pertama, panggilan itu akan masuk ke instance kelas konfigurasi kita.
Perubahan metode pengakses atribut di config_class
sedikit lebih rumit. Untuk memahaminya, pertama-tama kita harus memahami apa sebenarnya yang dilakukan attr_accessor
di balik layar. Ambil panggilan attr_accessor
berikut sebagai contoh:
class SomeClass attr_accessor :foo, :bar end
Ini sama dengan mendefinisikan metode pembaca dan penulis untuk setiap atribut yang ditentukan:
class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end
Jadi ketika kami menulis attr_accessor *attrs
dalam kode aslinya, Ruby mendefinisikan metode pembaca dan penulis atribut untuk kami untuk setiap atribut di attrs
—yaitu, kami mendapatkan metode pengakses standar berikut: app_id
, app_id=
, title
, title=
dan seterusnya di. Di versi baru kami, kami ingin mempertahankan metode penulis standar agar tugas seperti ini tetap berfungsi dengan baik:
MyApp.config.app_ => "not_my_app"
Kami dapat terus menghasilkan metode penulis secara otomatis dengan memanggil attr_writer *attrs
. Namun, kita tidak dapat lagi menggunakan metode pembaca standar, karena metode tersebut juga harus mampu menulis atribut untuk mendukung sintaks baru ini:
MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end
Untuk menghasilkan metode pembaca sendiri, kita mengulang array attrs
dan mendefinisikan metode untuk setiap atribut yang mengembalikan nilai saat ini dari variabel instan yang cocok jika tidak ada nilai baru yang diberikan dan menulis nilai baru jika ditentukan:
not_provided = Object.new # ... attrs.each do |attr| define_method attr do |value = not_provided| if value === not_provided instance_variable_get("@#{attr}") else instance_variable_set("@#{attr}", value) end end end
Di sini kita menggunakan metode instance_variable_get
Ruby untuk membaca variabel instance dengan nama arbitrer, dan instance_variable_set
untuk menetapkan nilai baru padanya. Sayangnya nama variabel harus diawali dengan tanda “@” dalam kedua kasus—karenanya interpolasi string.
Anda mungkin bertanya-tanya mengapa kita harus menggunakan objek kosong sebagai nilai default untuk "tidak disediakan" dan mengapa kita tidak bisa menggunakan nil
untuk tujuan itu. Alasannya sederhana— nil
adalah nilai valid yang mungkin ingin disetel seseorang untuk atribut konfigurasi. Jika kami menguji nil
, kami tidak akan dapat membedakan dua skenario ini:
MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end
Objek kosong yang disimpan di not_provided
hanya akan sama dengan dirinya sendiri, jadi dengan cara ini kami dapat memastikan bahwa tidak ada yang akan meneruskannya ke metode kami dan menyebabkan pembacaan yang tidak diinginkan alih-alih penulisan.
Menambahkan Dukungan untuk Referensi
Ada satu fitur lagi yang bisa kita tambahkan untuk membuat modul kita lebih fleksibel—kemampuan untuk mereferensikan atribut konfigurasi dari yang lain:
MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session"
Di sini kami menambahkan referensi dari cookie_name
ke atribut app_id
. Perhatikan bahwa ekspresi yang berisi referensi diteruskan sebagai blok—ini diperlukan untuk mendukung evaluasi tertunda dari nilai atribut. Idenya adalah untuk hanya mengevaluasi blok nanti saat atribut dibaca dan bukan saat didefinisikan—jika tidak, hal-hal lucu akan terjadi jika kita mendefinisikan atribut dalam urutan "salah":
SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funny
Jika ekspresi dibungkus dalam blok, itu akan mencegahnya dievaluasi segera. Sebagai gantinya, kita dapat menyimpan blok untuk dieksekusi nanti ketika nilai atribut diambil:
SomeClass.configure do foo { "#{bar}_baz" } # stores block, does not evaluate it yet bar "hello" end SomeClass.config.foo # `foo` evaluated here => "hello_baz" # correct!
Kami tidak perlu membuat perubahan besar pada modul yang Configurable
untuk menambahkan dukungan untuk evaluasi tertunda menggunakan blok. Faktanya, kita hanya perlu mengubah definisi metode atribut:
define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end
Saat menyetel atribut, block || value
ekspresi block || value
menyimpan blok jika ada yang diteruskan, atau jika tidak, menyimpan nilai. Kemudian, ketika atribut dibaca nanti, kami memeriksa apakah itu blok dan mengevaluasinya menggunakan instance_eval
jika ya, atau jika itu bukan blok, kami mengembalikannya seperti yang kami lakukan sebelumnya.
Referensi pendukung dilengkapi dengan peringatan dan kasus tepinya sendiri, tentu saja. Misalnya, Anda mungkin dapat mengetahui apa yang terjadi jika Anda membaca salah satu atribut dalam konfigurasi ini:
SomeClass.configure do foo { bar } bar { foo } end
Modul Selesai
Pada akhirnya, kami telah mendapatkan modul yang cukup rapi untuk membuat kelas arbitrer dapat dikonfigurasi dan kemudian menentukan nilai konfigurasi tersebut menggunakan DSL yang bersih dan sederhana yang juga memungkinkan kami mereferensikan satu atribut konfigurasi dari yang lain:
class MyApp include Configurable.with(:app_id, :title, :cookie_name) # ... end SomeClass.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } end
Berikut adalah versi terakhir dari modul yang mengimplementasikan DSL kami—total 36 baris kode:
module Configurable def self.with(*attrs) not_provided = Object.new config_class = Class.new do attrs.each do |attr| define_method attr do |value = not_provided, &block| if value === not_provided && block.nil? result = instance_variable_get("@#{attr}") result.is_a?(Proc) ? instance_eval(&result) : result else instance_variable_set("@#{attr}", block || value) end end end attr_writer *attrs end class_methods = Module.new do define_method :config do @config ||= config_class.new end def configure(&block) config.instance_eval(&block) end end Module.new do singleton_class.send :define_method, :included do |host_class| host_class.extend class_methods end end end end
Melihat semua keajaiban Ruby ini dalam sepotong kode yang hampir tidak dapat dibaca dan oleh karena itu sangat sulit untuk dipelihara, Anda mungkin bertanya-tanya apakah semua upaya ini sepadan hanya untuk membuat bahasa khusus domain kami sedikit lebih bagus. Jawaban singkatnya adalah tergantung—yang membawa kita ke topik terakhir artikel ini.
Ruby DSL—Kapan Digunakan dan Kapan Tidak Digunakan
Anda mungkin telah memperhatikan saat membaca langkah-langkah implementasi DSL kami bahwa, karena kami membuat sintaks eksternal bahasa lebih bersih dan lebih mudah digunakan, kami harus menggunakan semakin banyak trik metaprogramming di bawah tenda untuk mewujudkannya. Ini menghasilkan implementasi yang akan sangat sulit dipahami dan dimodifikasi di masa mendatang. Seperti banyak hal lain dalam pengembangan perangkat lunak, ini juga merupakan tradeoff yang harus diperiksa dengan cermat.
Agar bahasa khusus domain sepadan dengan biaya implementasi dan pemeliharaannya, ia harus membawa lebih banyak manfaat ke tabel. Ini biasanya dicapai dengan membuat bahasa dapat digunakan kembali dalam sebanyak mungkin skenario yang berbeda, sehingga mengamortisasi total biaya antara banyak kasus penggunaan yang berbeda. Kerangka kerja dan pustaka lebih cenderung berisi DSL mereka sendiri karena mereka digunakan oleh banyak pengembang, yang masing-masing dapat menikmati manfaat produktivitas dari bahasa yang disematkan tersebut.
Jadi, sebagai prinsip umum, buatlah DSL hanya jika Anda, pengembang lain, atau pengguna akhir aplikasi Anda akan banyak memanfaatkannya. Jika Anda membuat DSL, pastikan untuk menyertakan rangkaian pengujian yang komprehensif dengannya, serta mendokumentasikan sintaksisnya dengan benar karena akan sangat sulit untuk mengetahuinya dari implementasi saja. Masa depan Anda dan sesama pengembang akan berterima kasih untuk itu.
Bacaan Lebih Lanjut di Blog Teknik Toptal:
- Bagaimana Pendekatan Menulis Penerjemah Dari Awal