Tworzenie Ruby DSL: przewodnik po zaawansowanym metaprogramowaniu

Opublikowany: 2022-03-11

Języki specyficzne dla domeny (DSL) są niezwykle potężnym narzędziem ułatwiającym programowanie lub konfigurowanie złożonych systemów. Są one również wszędzie — jako inżynier oprogramowania najprawdopodobniej codziennie korzystasz z kilku różnych DSL.

W tym artykule dowiesz się, jakie są języki specyficzne dla domeny, kiedy powinny być używane i na koniec, jak możesz stworzyć własne DSL w Ruby przy użyciu zaawansowanych technik metaprogramowania.

Ten artykuł opiera się na wprowadzeniu Nikoli Todorovica do metaprogramowania w Ruby, opublikowanym również na blogu Toptal. Więc jeśli jesteś nowy w metaprogramowaniu, upewnij się, że najpierw to przeczytałeś.

Co to jest język specyficzny dla domeny?

Ogólna definicja DSL jest taka, że ​​są to języki wyspecjalizowane w określonej domenie aplikacji lub przypadku użycia. Oznacza to, że możesz ich używać tylko do określonych rzeczy — nie nadają się do tworzenia oprogramowania ogólnego przeznaczenia. Jeśli to brzmi szeroko, to dlatego, że tak jest – DSL mają wiele różnych kształtów i rozmiarów. Oto kilka ważnych kategorii:

  • Języki znaczników, takie jak HTML i CSS, są przeznaczone do opisywania konkretnych rzeczy, takich jak struktura, zawartość i style stron internetowych. Nie da się za ich pomocą napisać dowolnych algorytmów, więc pasują do opisu DSL.
  • Języki makr i zapytań (np. SQL) znajdują się na szczycie określonego systemu lub innego języka programowania i zazwyczaj mają ograniczone możliwości. Dlatego oczywiście kwalifikują się jako języki specyficzne dla domeny.
  • Wiele DSL nie ma własnej składni — zamiast tego używają składni ustalonego języka programowania w sprytny sposób, który sprawia wrażenie używania oddzielnego mini-języka.

Ta ostatnia kategoria nazywa się wewnętrznym łączem DSL i jest jedną z nich, którą wkrótce stworzymy jako przykład. Ale zanim do tego przejdziemy, spójrzmy na kilka dobrze znanych przykładów wewnętrznych DSL. Składnia definicji trasy w Railsach jest jedną z nich:

 Rails.application.routes.draw do root to: "pages#main" resources :posts do get :preview resources :comments, only: [:new, :create, :destroy] end end

To jest kod Rubiego, ale bardziej przypomina niestandardowy język definiowania tras, dzięki różnym technikom metaprogramowania, które umożliwiają tak przejrzysty i łatwy w użyciu interfejs. Zauważ, że struktura DSL jest zaimplementowana przy użyciu bloków Rubiego, a wywołania metod, takie jak get i resources są używane do definiowania słów kluczowych tego mini-języka.

Metaprogramowanie jest jeszcze intensywniej wykorzystywane w bibliotece testowej 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

Ten fragment kodu zawiera również przykłady interfejsów płynnych , które umożliwiają odczytywanie na głos deklaracji jako zwykłych angielskich zdań, co znacznie ułatwia zrozumienie działania kodu:

 # 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

Innym przykładem płynnego interfejsu jest interfejs zapytań ActiveRecord i Arel, który wykorzystuje wewnętrznie abstrakcyjne drzewo składni do budowania złożonych zapytań SQL:

 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`

Chociaż czysta i ekspresyjna składnia Rubiego wraz z jego możliwościami metaprogramowania sprawia, że ​​jest on wyjątkowo dostosowany do budowania języków specyficznych dla domeny, DSL istnieją również w innych językach. Oto przykład testu JavaScript przy użyciu frameworka 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!"); }); }); });

Ta składnia może nie jest tak przejrzysta jak w przykładach Rubiego, ale pokazuje, że dzięki sprytnemu nazewnictwu i kreatywnemu użyciu składni, wewnętrzne DSL mogą być tworzone przy użyciu prawie każdego języka.

Zaletą wewnętrznych łączy DSL jest to, że nie wymagają one oddzielnego parsera, co może być bardzo trudne do prawidłowego zaimplementowania. A ponieważ używają składni języka, w którym są zaimplementowane, bezproblemowo integrują się z resztą kodu.

W zamian musimy zrezygnować ze swobody syntaktycznej — wewnętrzne DSL muszą być poprawne składniowo w języku implementacji. To, ile trzeba iść na kompromis w tym zakresie, zależy w dużej mierze od wybranego języka, przy czym na jednym końcu spektrum znajdują się języki gadatliwe, statycznie typowane, takie jak Java i VB.NET, a na drugim języki dynamiczne z rozbudowanymi możliwościami metaprogramowania, takie jak Ruby. koniec.

Budowanie własnego — Ruby DSL do konfiguracji klas

Przykładowe łącze DSL, które zamierzamy zbudować w Ruby, jest silnikiem konfiguracyjnym wielokrotnego użytku, służącym do określania atrybutów konfiguracyjnych klasy Ruby przy użyciu bardzo prostej składni. Dodawanie możliwości konfiguracyjnych do klasy jest bardzo powszechnym wymogiem w świecie Ruby, zwłaszcza jeśli chodzi o konfigurowanie zewnętrznych klejnotów i klientów API. Typowym rozwiązaniem jest taki interfejs:

 MyApp.configure do |config| config.app_ config.title = "My App" config.cookie_name = "my_app_session" end

Najpierw zaimplementujmy ten interfejs — a potem, używając go jako punktu wyjścia, możemy go ulepszyć krok po kroku, dodając więcej funkcji, czyszcząc składnię i umożliwiając ponowne wykorzystanie naszej pracy.

Czego potrzebujemy, aby ten interfejs działał? Klasa MyApp powinna mieć metodę klasy configure , która pobiera blok, a następnie wykonuje ten blok, poddając się mu, przekazując obiekt konfiguracyjny, który ma metody dostępu do odczytywania i zapisywania wartości konfiguracyjnych:

 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

Po uruchomieniu bloku konfiguracyjnego możemy łatwo uzyskać dostęp i modyfikować wartości:

 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"

Jak dotąd ta implementacja nie wydaje się być językiem niestandardowym na tyle, aby można go było uznać za DSL. Ale zróbmy krok po kroku. Następnie oddzielimy funkcjonalność konfiguracji od klasy MyApp i uczynimy ją wystarczająco ogólną, aby mogła być używana w wielu różnych przypadkach użycia.

Możliwość wielokrotnego użytku

W tej chwili, gdybyśmy chcieli dodać podobne możliwości konfiguracyjne do innej klasy, musielibyśmy skopiować zarówno klasę Configuration , jak i powiązane z nią metody konfiguracji do tej innej klasy, a także edytować listę attr_accessor , aby zmienić akceptowane atrybuty konfiguracji. Aby tego uniknąć, przenieśmy funkcje konfiguracyjne do osobnego modułu o nazwie Configurable . Dzięki temu nasza klasa MyApp będzie wyglądać tak:

 class MyApp #BOLD include Configurable #BOLDEND # ... end

Wszystko związane z konfiguracją zostało przeniesione do modułu 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

Niewiele się tutaj zmieniło, z wyjątkiem nowej metody self.included . Potrzebujemy tej metody, ponieważ uwzględnienie modułu miesza tylko metody instancji, więc nasze metody klasy config i configure nie zostaną domyślnie dodane do klasy hosta. Jednakże, jeśli zdefiniujemy specjalną metodę wywoływaną included do modułu, Ruby wywoła ją za każdym razem, gdy ten moduł zostanie dołączony do klasy. Tam możemy ręcznie rozszerzyć klasę hosta za pomocą metod w 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

Jeszcze nie skończyliśmy — naszym następnym krokiem jest umożliwienie określenia obsługiwanych atrybutów w klasie hosta, która zawiera moduł Configurable . Takie rozwiązanie wyglądałoby ładnie:

 class MyApp #BOLD include Configurable.with(:app_id, :title, :cookie_name) #BOLDEND # ... end

Być może nieco zaskakujące, powyższy kod jest poprawny pod względem składniowym — include nie jest słowem kluczowym, ale zwykłą metodą, która jako parametr oczekuje obiektu Module . Dopóki przekażemy mu wyrażenie zwracające Module , z radością go uwzględni. Tak więc, zamiast włączać Configurable bezpośrednio, potrzebujemy metody with nazwą, która będzie generować nowy moduł, który jest dostosowany z określonymi atrybutami:

 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

Tutaj jest co rozpakować. Cały moduł Configurable składa się teraz tylko z jednej metody with , w której wszystko dzieje się w ramach tej metody. Najpierw tworzymy nową anonimową klasę z Class.new do przechowywania naszych metod dostępu do atrybutów. Ponieważ Class.new przyjmuje definicję klasy jako blok, a bloki mają dostęp do zmiennych zewnętrznych, jesteśmy w stanie bez problemu przekazać zmienną attrs do attr_accessor .

 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

Fakt, że bloki w Rubym mają dostęp do zmiennych zewnętrznych, jest również powodem, dla którego czasami nazywa się je domknięciami , ponieważ zawierają, lub „zamykają” środowisko zewnętrzne, w którym zostały zdefiniowane. Zauważ, że użyłem wyrażenia „zdefiniowane w” a nie „wykonane w”. Zgadza się – niezależnie od tego, kiedy i gdzie ostatecznie zostaną wykonane nasze bloki define_method , zawsze będą miały dostęp do zmiennych config_class i class_methods , nawet po zakończeniu działania i zwróceniu metody with . Poniższy przykład ilustruje to zachowanie:

 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

Teraz, gdy wiemy już o tym zgrabnym zachowaniu bloków, możemy przejść dalej i zdefiniować anonimowy moduł w class_methods dla metod klasowych, które zostaną dodane do klasy hosta, gdy nasz wygenerowany moduł zostanie dołączony. Tutaj musimy użyć define_method do zdefiniowania metody config , ponieważ potrzebujemy dostępu do zewnętrznej zmiennej config_class z wnętrza metody. Zdefiniowanie metody za pomocą słowa kluczowego def nie dałoby nam tego dostępu, ponieważ zwykłe definicje metod z def nie są domknięciami – jednak define_method zajmuje blok, więc to zadziała:

 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`

Na koniec wywołujemy Module.new , aby utworzyć moduł, który zamierzamy zwrócić. Tutaj musimy zdefiniować naszą metodę self.included , ale niestety nie możemy tego zrobić za pomocą słowa kluczowego def , ponieważ metoda wymaga dostępu do zewnętrznej zmiennej class_methods . Dlatego musimy ponownie użyć define_method z blokiem, ale tym razem w klasie singleton modułu, ponieważ definiujemy metodę na samej instancji modułu. Aha, a ponieważ define_method jest prywatną metodą klasy singleton, musimy użyć send , aby ją wywołać, zamiast wywoływać ją bezpośrednio:

 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

Uff, to już było całkiem hardkorowe metaprogramowanie. Ale czy ta dodatkowa złożoność była tego warta? Zobacz, jak łatwy jest w użyciu i sam zdecyduj:

 class SomeClass include Configurable.with(:foo, :bar) # ... end SomeClass.configure do |config| config.foo = "wat" config.bar = "huh" end SomeClass.config.foo => "wat"

Ale możemy zrobić jeszcze lepiej. W następnym kroku posprzątamy trochę składnię bloku configure , aby nasz moduł był jeszcze wygodniejszy w użyciu.

Czyszczenie składni

Jest jeszcze jedna rzecz, która wciąż mnie niepokoi w naszej obecnej implementacji — musimy powtarzać config w każdym wierszu bloku konfiguracji. Właściwy DSL wiedziałby, że wszystko w bloku configure powinno zostać wykonane w kontekście naszego obiektu konfiguracyjnego i umożliwić nam osiągnięcie tego samego za pomocą tylko tego:

 MyApp.configure do app_id "my_app" title "My App" cookie_name "my_app_session" end

Zaimplementujmy to, dobrze? Wygląda na to, że będziemy potrzebować dwóch rzeczy. Po pierwsze, potrzebujemy sposobu na wykonanie bloku przekazanego do configure w kontekście obiektu konfiguracyjnego, tak aby wywołania metod w bloku trafiały do ​​tego obiektu. Po drugie, musimy zmienić metody akcesorów, aby zapisywały wartość, jeśli podano im argument, i odczytywały ją z powrotem po wywołaniu bez argumentu. Ewentualna implementacja wygląda tak:

 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

Prostszą zmianą jest tutaj uruchomienie bloku configure w kontekście obiektu konfiguracyjnego. Wywołanie metody instance_eval Rubiego na obiekcie umożliwia wykonanie dowolnego bloku kodu tak, jakby był on uruchomiony w tym obiekcie, co oznacza, że ​​gdy blok konfiguracyjny wywoła metodę app_id w pierwszym wierszu, wywołanie to zostanie skierowane do naszej instancji klasy konfiguracyjnej.

Zmiana metod akcesorów atrybutów w config_class jest nieco bardziej skomplikowana. Aby to zrozumieć, musimy najpierw zrozumieć, co dokładnie attr_accessor robi za kulisami. Weźmy na przykład następujące wywołanie attr_accessor :

 class SomeClass attr_accessor :foo, :bar end

Jest to równoważne zdefiniowaniu metody odczytującej i piszącej dla każdego określonego atrybutu:

 class SomeClass def foo @foo end def foo=(value) @foo = value end # and the same with `bar` end

Kiedy więc napisaliśmy attr_accessor *attrs w oryginalnym kodzie, Ruby zdefiniował dla nas metody odczytu i zapisu atrybutów dla każdego atrybutu w attrs — czyli otrzymaliśmy następujące standardowe metody akcesora: app_id , app_id= , title , title= i tak dalej na. W naszej nowej wersji chcemy zachować standardowe metody piszące, aby takie przypisania nadal działały poprawnie:

 MyApp.config.app_ => "not_my_app"

Możemy nadal automatycznie generować metody writer, wywołując attr_writer *attrs . Jednak nie możemy już używać standardowych metod czytnika, ponieważ muszą one również być w stanie napisać atrybut obsługujący tę nową składnię:

 MyApp.configure do app_id "my_app" # assigns a new value app_id # reads the stored value end

Aby samodzielnie wygenerować metody czytnika, zapętlamy tablicę attrs i definiujemy metodę dla każdego atrybutu, która zwraca bieżącą wartość pasującej zmiennej instancji, jeśli nie podano nowej wartości, i zapisuje nową wartość, jeśli jest ona określona:

 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

Tutaj używamy metody Ruby instance_variable_get , aby odczytać zmienną instancji o dowolnej nazwie, oraz instance_variable_set , aby przypisać jej nową wartość. Niestety nazwa zmiennej musi być poprzedzona znakiem „@” w obu przypadkach — stąd interpolacja ciągu.

Być może zastanawiasz się, dlaczego musimy użyć pustego obiektu jako domyślnej wartości dla „nie podano” i dlaczego nie możemy po prostu użyć do tego celu nil . Powód jest prosty — nil jest poprawną wartością, którą ktoś może chcieć ustawić dla atrybutu konfiguracyjnego. Gdybyśmy sprawdzili nil , nie bylibyśmy w stanie odróżnić tych dwóch scenariuszy:

 MyApp.configure do app_id nil # expectation: assigns nil app_id # expectation: returns current value end

Ten pusty obiekt przechowywany w not_provided będzie zawsze równy sobie, więc w ten sposób możemy być pewni, że nikt nie przekaże go do naszej metody i nie spowoduje niezamierzonego odczytu zamiast zapisu.

Dodawanie wsparcia dla referencji

Jest jeszcze jedna funkcja, którą moglibyśmy dodać, aby nasz moduł był jeszcze bardziej wszechstronny — możliwość odwoływania się do atrybutu konfiguracji z innego:

 MyApp.configure do app_id "my_app" title "My App" cookie_name { "#{app_id}_session" } End MyApp.config.cookie_name => "my_app_session"

Tutaj dodaliśmy odwołanie z cookie_name do atrybutu app_id . Zwróć uwagę, że wyrażenie zawierające odwołanie jest przekazywane jako blok — jest to konieczne do obsługi opóźnionego obliczania wartości atrybutu. Pomysł polega na tym, aby oceniać blok później dopiero po odczytaniu atrybutu, a nie po jego zdefiniowaniu — w przeciwnym razie wydarzyłyby się śmieszne rzeczy, gdybyśmy zdefiniowali atrybuty w „niewłaściwej” kolejności:

 SomeClass.configure do foo "#{bar}_baz" # expression evaluated here bar "hello" end SomeClass.config.foo => "_baz" # not actually funny

Jeśli wyrażenie jest opakowane w blok, uniemożliwi to jego natychmiastowe obliczenie. Zamiast tego możemy zapisać blok do wykonania później, gdy zostanie pobrana wartość atrybutu:

 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!

Nie musimy wprowadzać dużych zmian w module Configurable , aby dodać obsługę opóźnionej oceny za pomocą bloków. W rzeczywistości musimy tylko zmienić definicję metody atrybutów:

 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

Podczas ustawiania atrybutu block || value wyrażenie block || value zapisuje blok, jeśli został przekazany, lub w przeciwnym razie zapisuje wartość. Następnie, gdy atrybut jest później odczytywany, sprawdzamy, czy jest to blok i oceniamy go za pomocą instance_eval , jeśli tak, lub jeśli nie jest to blok, zwracamy go tak jak wcześniej.

Dodatkowe odniesienia mają oczywiście własne zastrzeżenia i przypadki brzegowe. Na przykład możesz prawdopodobnie dowiedzieć się, co się stanie, jeśli przeczytasz którykolwiek z atrybutów w tej konfiguracji:

 SomeClass.configure do foo { bar } bar { foo } end

Gotowy moduł

W końcu otrzymaliśmy całkiem zgrabny moduł do tworzenia konfigurowalnej dowolnej klasy, a następnie określania tych wartości konfiguracyjnych za pomocą czystego i prostego DSL, który pozwala nam również odwoływać się do jednego atrybutu konfiguracyjnego z innego:

 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

Oto ostateczna wersja modułu, który implementuje nasze DSL — łącznie 36 linii kodu:

 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

Patrząc na całą tę magię Rubinową w kawałku kodu, który jest prawie nieczytelny i dlatego bardzo trudny do utrzymania, możesz się zastanawiać, czy cały ten wysiłek był opłacalny tylko po to, aby trochę ładniejszy język naszej domeny. Krótka odpowiedź brzmi, że to zależy — co prowadzi nas do ostatniego tematu tego artykułu.

Ruby DSL — kiedy używać, a kiedy nie używać

Prawdopodobnie zauważyłeś, czytając kroki implementacji naszego DSL, że ponieważ uczyniliśmy zewnętrzną składnię języka czystszą i łatwiejszą w użyciu, musieliśmy używać coraz większej liczby sztuczek metaprogramowania pod maską, aby tak się stało. Zaowocowało to wdrożeniem, które będzie niezwykle trudne do zrozumienia i modyfikacji w przyszłości. Podobnie jak wiele innych rzeczy w tworzeniu oprogramowania, jest to również kompromis, który należy dokładnie zbadać.

Aby język specyficzny dla domeny był wart kosztów wdrożenia i utrzymania, musi przynieść jeszcze większą sumę korzyści do tabeli. Zwykle osiąga się to poprzez umożliwienie ponownego użycia języka w jak największej liczbie różnych scenariuszy, amortyzując w ten sposób całkowity koszt między wieloma różnymi przypadkami użycia. Struktury i biblioteki częściej zawierają własne DSL, ponieważ są one używane przez wielu programistów, z których każdy może czerpać korzyści z wydajności tych wbudowanych języków.

Tak więc, zgodnie z ogólną zasadą, buduj DSL tylko wtedy, gdy Ty, inni programiści lub użytkownicy końcowi Twojej aplikacji będziecie czerpać z nich wiele korzyści. Jeśli tworzysz DSL, upewnij się, że dołączyłeś do niego obszerny zestaw testów, a także odpowiednio udokumentuj jego składnię, ponieważ może być bardzo trudno rozszyfrować ją na podstawie samej implementacji. Future ty i twoi koledzy programiści będą ci za to wdzięczni.


Dalsza lektura na blogu Toptal Engineering:

  • Jak podejść do pisania tłumacza od podstaw