Programowanie deklaratywne: czy to prawda?
Opublikowany: 2022-03-11Programowanie deklaratywne jest obecnie dominującym paradygmatem w rozległym i zróżnicowanym zbiorze dziedzin, takich jak bazy danych, zarządzanie szablonami i konfiguracją.
Mówiąc w skrócie, programowanie deklaratywne polega na instruowaniu programu, co ma być zrobione, zamiast mówić mu, jak to zrobić. W praktyce podejście to obejmuje zapewnienie języka specyficznego dla domeny (DSL) do wyrażania tego, czego chce użytkownik, i osłanianie ich przed konstrukcjami niskiego poziomu (pętlami, warunkami, przypisaniami), które materializują pożądany stan końcowy.
Chociaż ten paradygmat jest znaczącym ulepszeniem w stosunku do podejścia imperatywnego, które zastąpił, twierdzę, że programowanie deklaratywne ma znaczące ograniczenia, ograniczenia, które omówię w tym artykule. Co więcej, proponuję podwójne podejście, które uchwyci zalety programowania deklaratywnego, jednocześnie znosząc jego ograniczenia.
OSTRZEŻENIE : Ten artykuł powstał w wyniku wieloletniej osobistej walki z narzędziami deklaratywnymi. Wiele z twierdzeń, które tutaj przedstawiam, nie jest dokładnie udowodnionych, a niektóre są nawet przedstawione w wartości nominalnej. Właściwa krytyka programowania deklaratywnego zajęłaby sporo czasu i wysiłku, a ja musiałbym cofnąć się i użyć wielu z tych narzędzi; moje serce nie jest w takim przedsięwzięciu. Celem tego artykułu jest podzielenie się z wami kilkoma przemyśleniami, bez wydawania ciosów i pokazanie, co zadziałało dla mnie. Jeśli zmagałeś się z deklaratywnymi narzędziami programistycznymi, możesz znaleźć wytchnienie i alternatywy. A jeśli podoba ci się paradygmat i jego narzędzia, nie traktuj mnie zbyt poważnie.
Jeśli programowanie deklaratywne działa dobrze, nie mogę powiedzieć inaczej .
Zalety programowania deklaratywnego
Zanim zbadamy ograniczenia programowania deklaratywnego, konieczne jest zrozumienie jego zalet.
Prawdopodobnie najbardziej udanym narzędziem programowania deklaratywnego jest relacyjna baza danych (RDB). Może to być nawet pierwsze narzędzie deklaratywne. W każdym razie RDB wykazują dwie właściwości, które uważam za archetypowe programowania deklaratywnego:
- Język specyficzny dla domeny (DSL) : uniwersalnym interfejsem relacyjnych baz danych jest DSL o nazwie Structured Query Language, najczęściej znany jako SQL.
- DSL ukrywa przed użytkownikiem warstwę niższego poziomu : od czasu pierwszego artykułu Edgara F. Codda na temat RDB, jasne jest, że moc tego modelu polega na oddzieleniu pożądanych zapytań od leżących u ich podstaw pętli, indeksów i ścieżek dostępu, które je implementują.
Przed RDB dostęp do większości systemów baz danych odbywał się za pomocą kodu imperatywnego, który w dużym stopniu zależy od szczegółów niskiego poziomu, takich jak kolejność rekordów, indeksy i fizyczne ścieżki do samych danych. Ponieważ te elementy zmieniają się w czasie, kod często przestaje działać z powodu pewnej podstawowej zmiany w strukturze danych. Powstały kod jest trudny do napisania, trudny do debugowania, trudny do odczytania i trudny do utrzymania. Wyjdę i powiem, że większość tego kodu była prawdopodobnie długa, pełna przysłowiowych szczurzych gniazd warunkowych, powtórzeń i subtelnych, zależnych od stanu błędów.
W obliczu tego, bazy danych RDB zapewniły twórcom systemów ogromny skok wydajności. Teraz, zamiast tysięcy linijek imperatywnego kodu, miałeś jasno zdefiniowany schemat danych plus setki (a nawet tylko dziesiątki) zapytań. W rezultacie aplikacje musiały radzić sobie tylko z abstrakcyjną, znaczącą i trwałą reprezentacją danych oraz łączyć je za pomocą wydajnego, ale prostego języka zapytań. RDB prawdopodobnie podniósł produktywność programistów i firm, które ich zatrudniały, o rząd wielkości.
Jakie są najczęściej wymieniane zalety programowania deklaratywnego?
- Czytelność/użyteczność : DSL jest zwykle bliższy językowi naturalnemu (np. angielskiemu) niż pseudokodowi, dlatego jest bardziej czytelny i łatwiejszy do nauczenia się przez nie-programistów.
- Zwięzłość : znaczna część schematu jest wyabstrahowana przez DSL, pozostawiając mniej linii do wykonania tej samej pracy.
- Ponowne użycie : łatwiej jest tworzyć kod, który można wykorzystać do różnych celów; coś, co jest notorycznie trudne, gdy używa się konstrukcji imperatywnych.
- Idempotencja : możesz pracować ze stanami końcowymi i pozwolić programowi rozwiązać to za Ciebie. Na przykład za pomocą operacji upsert można wstawić wiersz, jeśli go tam nie ma, lub zmodyfikować go, jeśli już tam jest, zamiast pisać kod, który poradzi sobie z obydwoma przypadkami.
- Odzyskiwanie błędów : łatwo jest określić konstrukcję, która zatrzyma się na pierwszym błędzie, zamiast dodawać detektory błędów dla każdego możliwego błędu. (Jeśli kiedykolwiek napisałeś trzy zagnieżdżone wywołania zwrotne w node.js, wiesz, co mam na myśli.)
- Przejrzystość referencyjna : chociaż ta zaleta jest powszechnie kojarzona z programowaniem funkcjonalnym, w rzeczywistości jest ważna dla każdego podejścia, które minimalizuje ręczną obsługę stanu i opiera się na efektach ubocznych.
- Przemienność : możliwość wyrażenia stanu końcowego bez konieczności określania rzeczywistej kolejności, w jakiej zostanie zaimplementowany.
Chociaż wszystkie powyższe są powszechnie przytaczanymi zaletami programowania deklaratywnego, chciałbym skondensować je w dwóch cechach, które posłużą jako zasady przewodnie przy proponowaniu alternatywnego podejścia.
- Warstwa wysokiego poziomu dostosowana do konkretnej dziedziny : programowanie deklaratywne tworzy warstwę wysokiego poziomu przy użyciu informacji z dziedziny, do której ma zastosowanie. Oczywiste jest, że jeśli mamy do czynienia z bazami danych, potrzebujemy zestawu operacji do obsługi danych. Większość z siedmiu powyższych zalet wynika z tworzenia warstwy wysokiego poziomu, która jest precyzyjnie dopasowana do określonej dziedziny problemu.
- Poka-yoke (odporność na głupoty) : warstwa wysokiego poziomu dostosowana do domeny ukrywa niezbędne szczegóły implementacji. Oznacza to, że popełniasz znacznie mniej błędów, ponieważ niskopoziomowe szczegóły systemu są po prostu niedostępne. To ograniczenie eliminuje wiele klas błędów z Twojego kodu.
Dwa problemy z programowaniem deklaratywnym
W kolejnych dwóch rozdziałach przedstawię dwa główne problemy programowania deklaratywnego: odrębność i brak rozwinięcia . Każda krytyka potrzebuje swojego straszaka, więc użyję systemów szablonów HTML jako konkretnego przykładu wad programowania deklaratywnego.
Problem z DSL: odrębność
Wyobraź sobie, że musisz napisać aplikację internetową z nietrywialną liczbą wyświetleń. Zakodowanie tych widoków na stałe w zestawie plików HTML nie wchodzi w grę, ponieważ wiele elementów tych stron się zmienia.
Najprostsze rozwiązanie, które polega na generowaniu kodu HTML przez łączenie ciągów znaków, wydaje się tak okropne, że szybko zaczniesz szukać alternatywy. Standardowym rozwiązaniem jest zastosowanie systemu szablonów. Chociaż istnieją różne typy systemów szablonów, na potrzeby tej analizy ominiemy ich różnice. Możemy uznać, że wszystkie z nich są podobne, ponieważ główną misją systemów szablonów jest zapewnienie alternatywy dla kodu, który łączy ciągi HTML za pomocą instrukcji warunkowych i pętli, podobnie jak RDB pojawiły się jako alternatywa dla kodu, który zapętlił się przez rekordy danych.
Załóżmy, że korzystamy ze standardowego systemu szablonów; napotkasz trzy źródła tarcia, które wymienię w porządku rosnącym według ważności. Po pierwsze, szablon koniecznie znajduje się w pliku innym niż twój kod. Ponieważ system szablonów używa DSL, składnia jest inna, więc nie może znajdować się w tym samym pliku. W prostych projektach, w których liczba plików jest niewielka, konieczność trzymania oddzielnych plików szablonów może zduplikować lub potroić liczbę plików.
Otwieram wyjątek dla szablonów Embedded Ruby (ERB), ponieważ są one zintegrowane z kodem źródłowym Ruby. Inaczej jest w przypadku narzędzi inspirowanych ERB napisanych w innych językach, ponieważ te szablony muszą być również przechowywane jako różne pliki.
Drugim źródłem problemów jest to, że DSL ma własną składnię, inną niż język programowania. W związku z tym modyfikacja DSL (nie mówiąc już o pisaniu własnego) jest znacznie trudniejsza. Aby przejść pod maskę i zmienić narzędzie, musisz nauczyć się tokenizacji i parsowania, co jest interesujące i trudne, ale trudne. Tak się składa, że postrzegam to jako wadę.
Możesz zapytać: „Dlaczego u licha miałbyś modyfikować swoje narzędzie? Jeśli robisz standardowy projekt, dobrze napisane standardowe narzędzie powinno pasować do wymagań”. Może tak może nie.
DSL nigdy nie ma pełnej mocy języka programowania. Gdyby tak było, nie byłby to już DSL, ale raczej pełny język programowania.
Ale czy nie o to chodzi w DSL? Nie mieć pełnej mocy języka programowania, aby osiągnąć abstrakcję i wyeliminować większość źródeł błędów? Może tak. Jednak większość DSL zaczyna się od prostych, a następnie stopniowo włącza coraz większą liczbę funkcji języka programowania, aż w rzeczywistości staje się jednym. Doskonałym przykładem są systemy szablonów. Przyjrzyjmy się standardowym cechom systemów szablonów i ich korelacji z udogodnieniami języka programowania:
- Zamień tekst w szablonie : podstawianie zmiennych.
- Powtórzenie szablonu : pętle.
- Unikaj drukowania szablonu, jeśli warunek nie jest spełniony : warunkowe.
- Części : podprogramy.
- Pomocnicy : podprogramy (jedyną różnicą w przypadku częściowych jest to, że pomocnicy mogą uzyskać dostęp do podstawowego języka programowania i wypuścić cię z kaftana bezpieczeństwa DSL).
Argument, że DSL jest ograniczony, ponieważ jednocześnie pożąda i odrzuca moc języka programowania, jest wprost proporcjonalny do stopnia, w jakim cechy DSL są bezpośrednio odwzorowane na cechy języka programowania . W przypadku SQL argument jest słaby, ponieważ większość rzeczy, które oferuje SQL, nie przypomina tego, co można znaleźć w normalnym języku programowania. Na drugim końcu spektrum znajdują się systemy szablonów, w których praktycznie każda funkcja powoduje, że DSL zbliża się do BASIC.
Cofnijmy się teraz i rozważmy te trzy podstawowe źródła tarcia, podsumowane pojęciem odrębności . Ponieważ jest oddzielny, DSL musi znajdować się w osobnym pliku; trudniej jest go modyfikować (a jeszcze trudniej napisać własne) i (często, ale nie zawsze) wymaga dodawania, jeden po drugim, funkcji, których brakuje w prawdziwym języku programowania.
Oddzielność jest nieodłącznym problemem każdego DSL, bez względu na to, jak dobrze jest zaprojektowane.
Przejdźmy teraz do drugiego problemu narzędzi deklaratywnych, który jest powszechny, ale nie jest immanentny.
Kolejny problem: brak rozwinięcia prowadzi do złożoności
Gdybym napisał ten artykuł kilka miesięcy temu, ta sekcja zostałaby nazwana Najbardziej deklaratywnymi narzędziami #@!$#@! Złożona, ale nie wiem dlaczego . W trakcie pisania tego artykułu znalazłem lepszy sposób ujmowania tego: większość narzędzi deklaratywnych jest o wiele bardziej złożona niż musi być . Resztę tej sekcji poświęcę na wyjaśnianie, dlaczego. Do analizy złożoności narzędzia proponuję miarę zwaną luką złożoności . Luka w złożoności to różnica między rozwiązaniem danego problemu za pomocą narzędzia a rozwiązaniem go na niższym poziomie (przypuszczalnie zwykły kod imperatywny), który narzędzie zamierza zastąpić. Kiedy pierwsze rozwiązanie jest bardziej złożone niż drugie, mamy do czynienia z luką w złożoności. Przez bardziej złożony mam na myśli więcej linii kodu, kod, który jest trudniejszy do odczytania, trudniejszy do zmodyfikowania i trudniejszy w utrzymaniu, ale niekoniecznie wszystkie naraz.
Należy pamiętać, że nie porównujemy rozwiązania niższego poziomu z najlepszym możliwym narzędziem, ale raczej z brakiem narzędzia. Odzwierciedla to medyczną zasadę „Po pierwsze nie szkodzić” .
Oznaki narzędzia z dużą luką w złożoności to:
- Coś, co zajmuje kilka minut, aby szczegółowo opisać w imperatywnych terminach, zajmie wiele godzin kodowania za pomocą narzędzia, nawet jeśli wiesz, jak go używać.
- Czujesz, że ciągle pracujesz wokół narzędzia, a nie z narzędziem.
- Walczysz o rozwiązanie prostego problemu, który bezpośrednio należy do domeny używanego narzędzia, ale najlepsza odpowiedź Stack Overflow, którą znajdziesz, opisuje obejście problemu .
- Kiedy ten bardzo prosty problem może zostać rozwiązany przez pewną funkcję (która nie istnieje w narzędziu) i widzisz w bibliotece problem z Github, który zawiera długą dyskusję na temat tej funkcji z przeplatanymi +1 -kami.
- Przewlekłe, swędzące pragnienie, by porzucić narzędzie i zrobić wszystko samemu wewnątrz pętli for.
Mogłem paść ofiarą emocji, ponieważ systemy szablonów nie są aż tak złożone, ale ta stosunkowo niewielka luka w złożoności nie jest zasługą ich projektu, ale raczej dlatego, że domena zastosowania jest dość prosta (pamiętaj, że tutaj tylko generujemy HTML ). Ilekroć to samo podejście jest stosowane w przypadku bardziej złożonej domeny (takiej jak zarządzanie konfiguracją), luka w złożoności może szybko zamienić Twój projekt w bagno.
To powiedziawszy, niekoniecznie jest niedopuszczalne, aby narzędzie było nieco bardziej złożone niż niższy poziom, który zamierza zastąpić; jeśli narzędzie daje kod, który jest bardziej czytelny, zwięzły i poprawny, może być tego wart. Jest to problem, gdy narzędzie jest kilka razy bardziej złożone niż problem, który zastępuje; jest to zdecydowanie niedopuszczalne. Brian Kernighan stwierdził, że „ Kontrolowanie złożoności jest istotą programowania komputerowego. „Jeśli narzędzie znacznie zwiększa złożoność twojego projektu, po co w ogóle go używać?
Pytanie brzmi, dlaczego niektóre narzędzia deklaratywne są o wiele bardziej złożone niż to konieczne? Myślę, że błędem byłoby zrzucanie winy na kiepski projekt. Takie ogólne wyjaśnienie, zmasowany atak ad-hominem na autorów tych narzędzi, nie jest sprawiedliwe. Musi być dokładniejsze i bardziej pouczające wyjaśnienie.
Twierdzę, że każde narzędzie, które oferuje interfejs wysokiego poziomu do wyabstrahowania niższego poziomu, musi rozwinąć ten wyższy poziom od niższego. Koncepcja rozwijania się wywodzi z opus magnum Christophera Alexandra The Nature of Order – w szczególności z tomu II. Podsumowanie konsekwencji tej monumentalnej pracy dla projektowania oprogramowania wykracza (beznadziejnie) poza zakres tego artykułu (nie wspominając o moim zrozumieniu); Wierzę, że jego wpływ będzie ogromny w nadchodzących latach. Poza tym artykułem wykracza poza rygorystyczną definicję procesów rozwijania. Użyję tutaj pojęcia w sposób heurystyczny.
Proces rozwijania to taki, który w sposób stopniowy tworzy dalszą strukturę bez negacji już istniejącej. Na każdym kroku każda zmiana (lub zróżnicowanie, by użyć terminu Aleksandra) pozostaje w harmonii z każdą poprzednią strukturą, podczas gdy poprzednia struktura jest po prostu skrystalizowaną sekwencją przeszłych zmian.
Co ciekawe, Unix jest świetnym przykładem rozwijania wyższego poziomu z niższego. W Uniksie dwie złożone cechy systemu operacyjnego, zadania wsadowe i współprogramy (potoki), są po prostu rozszerzeniami podstawowych poleceń. Ze względu na pewne fundamentalne decyzje projektowe, takie jak uczynienie wszystkiego strumieniem bajtów, powłoka będąca programem w przestrzeni użytkownika i standardowymi plikami I/O, Unix jest w stanie zapewnić te wyrafinowane funkcje przy minimalnej złożoności.
Aby podkreślić, dlaczego są to doskonałe przykłady rozwoju, chciałbym przytoczyć kilka fragmentów artykułu Dennisa Ritchiego, jednego z autorów Uniksa, z 1979 roku:
W zadaniach wsadowych :
… nowy schemat sterowania procesem natychmiast sprawił, że niektóre bardzo cenne funkcje stały się banalne do wdrożenia; na przykład odłączone procesy (z
&
) i rekurencyjne użycie powłoki jako polecenia. Większość systemów musi dostarczać pewnego rodzaju specjalne narzędzie dobatch job submission
oraz specjalny interpreter poleceń dla plików innych niż używane interaktywnie.
Na współprogramach :
Geniusz potoku uniksowego polega właśnie na tym, że jest zbudowany z tych samych poleceń, które są stale używane w sposób simpleks.
Twierdzę, że ta elegancja i prostota wynika z rozwijającego się procesu. Zadania wsadowe i współprogramy są rozwijane z poprzednich struktur (polecenia uruchamiane w powłoce przestrzeni użytkownika). Uważam, że ze względu na minimalistyczną filozofię i ograniczone zasoby zespołu, który stworzył Unix, system ewoluował stopniowo i jako taki był w stanie włączyć zaawansowane funkcje bez odwracania się od podstawowych, ponieważ nie było wystarczających zasobów, aby zrób inaczej.
W przypadku braku procesu rozwijania, wysoki poziom będzie znacznie bardziej złożony niż to konieczne. Innymi słowy, złożoność większości narzędzi deklaratywnych wynika z faktu, że ich wysoki poziom nie wyrasta z niskiego poziomu, który zamierzają zastąpić.
Ten brak rozwinięcia , jeśli wybaczysz neologizm, jest rutynowo usprawiedliwiony koniecznością osłony użytkownika przed niższym poziomem. Nacisk na poka-yoke (ochrona użytkownika przed błędami niskiego poziomu) odbywa się kosztem dużej luki w złożoności, która jest autodestrukcyjna, ponieważ dodatkowa złożoność generuje nowe klasy błędów. Aby dodać obrazę do obrażeń, te klasy błędów nie mają nic wspólnego z domeną problemu, ale z samym narzędziem. Nie posunęlibyśmy się zbyt daleko, gdybyśmy określili te błędy jako jatrogenne.
Narzędzia do tworzenia szablonów deklaratywnych, przynajmniej w zastosowaniu do zadania generowania widoków HTML, są archetypowym przypadkiem wysokiego poziomu, który odwraca się od niskiego poziomu, który zamierza zastąpić. Jak to? Ponieważ wygenerowanie dowolnego nietrywialnego widoku wymaga logiki , a systemy szablonów, zwłaszcza te pozbawione logiki, wyrzucają logikę przez główne drzwi, a następnie przemycają część z powrotem przez drzwi kota.
Uwaga: jeszcze słabszym uzasadnieniem dużej luki w złożoności jest sytuacja, gdy narzędzie jest reklamowane jako magiczne , lub coś, co po prostu działa , nieprzezroczystość niskiego poziomu ma być atutem, ponieważ magiczne narzędzie ma zawsze działać bez Twojej wiedzy dlaczego lub jak. Z mojego doświadczenia wynika, że im bardziej magiczne jest narzędzie, tym szybciej przemienia mój entuzjazm w frustrację.
Ale co z oddzieleniem obaw? Czy widok i logika nie powinny pozostać rozdzielone? Podstawowym błędem jest tutaj umieszczenie logiki biznesowej i logiki prezentacji w tej samej torbie. Logika biznesowa z pewnością nie ma miejsca w szablonie, ale logika prezentacji mimo wszystko istnieje. Wykluczenie logiki z szablonów wypycha logikę prezentacji na serwer, gdzie jest niezręcznie dostosowana. Jasne sformułowanie tej kwestii zawdzięczam Aleksiejowi Boroninowi, który w tym artykule przedstawia to doskonale.
Mam wrażenie, że mniej więcej dwie trzecie pracy nad szablonem opiera się na logice prezentacji, podczas gdy druga trzecia zajmuje się ogólnymi problemami, takimi jak łączenie ciągów, zamykanie znaczników, unikanie znaków specjalnych i tak dalej. Jest to dwupłaszczyznowa, niskopoziomowa natura generowania widoków HTML. Systemy szablonowania odpowiednio radzą sobie z drugą połową, ale nie radzą sobie dobrze z pierwszą. Szablony pozbawione logiki całkowicie odwracają się od tego problemu, zmuszając Cię do niezręcznego rozwiązania. Inne systemy szablonów cierpią, ponieważ naprawdę muszą zapewnić nietrywialny język programowania, aby ich użytkownicy mogli faktycznie pisać logikę prezentacji.
Podsumowując; deklaratywne narzędzia do tworzenia szablonów cierpią, ponieważ:
- Gdyby wyszli ze swojej problematycznej domeny, musieliby zapewnić sposoby generowania logicznych wzorców;
- DSL, który zapewnia logikę, nie jest tak naprawdę DSL, ale językiem programowania. Zwróć uwagę, że inne domeny, takie jak zarządzanie konfiguracją, również cierpią na brak „rozwinięcia”.
Chciałbym zakończyć krytykę argumentem, który jest logicznie oderwany od wątku tego artykułu, ale głęboko współbrzmi z jego emocjonalnym rdzeniem: Mamy ograniczony czas na naukę. Życie jest krótkie, a do tego musimy pracować. W obliczu naszych ograniczeń musimy spędzać czas na nauce rzeczy, które będą przydatne i wytrzymają czas, nawet w obliczu szybko zmieniającej się technologii. Dlatego zachęcam do korzystania z narzędzi, które nie tylko dostarczają rozwiązania, ale faktycznie rzucają jasne światło na dziedzinę własnej przydatności. Bazy danych RDB uczą cię o danych, a Unix o koncepcjach systemu operacyjnego, ale z niezadowalającymi narzędziami, które się nie rozwijają, zawsze czułem, że poznaję zawiłości nieoptymalnego rozwiązania, pozostając w ciemności na temat natury problemu zamierza rozwiązać.

Heurystyka, którą proponuję rozważyć, polega na docenieniu narzędzi, które wyjaśniają ich problematyczną domenę, zamiast narzędzi, które zasłaniają ich problematyczną domenę za rzekomymi funkcjami .
Bliźniacze podejście
Aby przezwyciężyć dwa problemy programowania deklaratywnego, które tu przedstawiłem, proponuję podwójne podejście:
- Użyj języka specyficznego dla domeny struktury danych (dsDSL), aby przezwyciężyć odrębność.
- Stwórz wysoki poziom, który rozwija się z niższego poziomu, aby przezwyciężyć lukę w złożoności.
dsDSL
Struktura danych DSL (dsDSL) to DSL zbudowana ze struktur danych języka programowania . Podstawową ideą jest wykorzystanie podstawowych dostępnych struktur danych, takich jak ciągi, liczby, tablice, obiekty i funkcje, i połączenie ich w celu stworzenia abstrakcji w celu radzenia sobie z określoną dziedziną.
Chcemy zachować moc deklarowania struktur lub akcji (wysoki poziom) bez konieczności określania wzorców, które implementują te konstrukcje (niski poziom). Chcemy przezwyciężyć odrębność między DSL a naszym językiem programowania, abyśmy mogli swobodnie korzystać z pełnej mocy języka programowania, kiedy tylko tego potrzebujemy. Jest to nie tylko możliwe, ale i proste dzięki dsDSL.
Gdybyś zapytał mnie rok temu, pomyślałbym, że koncepcja dsDSL jest nowatorska, a pewnego dnia zdałem sobie sprawę, że sam JSON jest doskonałym przykładem tego podejścia! Przeanalizowany obiekt JSON składa się ze struktur danych, które deklaratywnie reprezentują wpisy danych w celu uzyskania zalet DSL, jednocześnie ułatwiając analizowanie i obsługę z poziomu języka programowania. (Mogą istnieć inne DSDSL, ale jak dotąd nie natknąłem się na żadne. Jeśli znasz jeden, naprawdę byłbym wdzięczny, że wspomniałeś o tym w sekcji komentarzy.)
Podobnie jak JSON, dsDSL ma następujące atrybuty:
- Składa się z bardzo małego zestawu funkcji: JSON ma dwie główne funkcje,
parse
istringify
. - Jego funkcje najczęściej otrzymują złożone i rekurencyjne argumenty: przeanalizowany JSON to tablica lub obiekt, który zwykle zawiera w sobie dalsze tablice i obiekty.
- Dane wejściowe tych funkcji są zgodne z bardzo określonymi formami: JSON ma jawny i ściśle wymuszony schemat walidacji, który odróżnia prawidłowe od nieprawidłowych struktur.
- Zarówno wejścia, jak i wyjścia tych funkcji mogą być zawarte i wygenerowane przez język programowania bez oddzielnej składni.
Ale dsDSL pod wieloma względami wykracza poza JSON. Stwórzmy dsDSL do generowania HTML za pomocą JavaScript. Później poruszę kwestię, czy to podejście można rozszerzyć na inne języki (spoiler: na pewno da się to zrobić w Ruby i Pythonie, ale chyba nie w C).
HTML to język znaczników składający się ze tags
oddzielonych nawiasami ostrymi ( <
i >
). Te tagi mogą mieć opcjonalne atrybuty i zawartość. Atrybuty to po prostu lista atrybutów klucz/wartość, a zawartość może być tekstem lub innymi tagami. Zarówno atrybuty, jak i zawartość są opcjonalne dla każdego tagu. Trochę upraszczam, ale jest to trafne.
Prostym sposobem reprezentowania tagu HTML w dsDSL jest użycie tablicy z trzema elementami: - Tag: ciąg. - Atrybuty: obiekt (zwykły, klucz/wartość) lub undefined
(jeśli nie są wymagane żadne atrybuty). - Zawartość: ciąg (tekst), tablica (kolejny znacznik) lub undefined
(jeśli nie ma zawartości).
Na przykład <a href="views">Index</a>
można zapisać jako ['a', {href: 'views'}, 'Index']
.
Jeśli chcemy osadzić ten element kotwicy w div
z links
do klas, możemy napisać: ['div', {class: 'links'}, ['a', {href: 'views'}, 'Index']]
.
Aby wyświetlić kilka tagów html na tym samym poziomie, możemy umieścić je w tablicy:
[ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]
Tę samą zasadę można zastosować do tworzenia wielu tagów w jednym tagu:
['body', [ ['h1', 'Hello!'], ['a', {href: 'views'}, 'Index'] ]]
Oczywiście ten dsDSL nie zaprowadzi nas daleko, jeśli nie wygenerujemy z niego kodu HTML. Potrzebujemy funkcji generate
, która pobierze nasze dsDSL i da ciąg z HTML. Więc jeśli uruchomimy generate (['a', {href: 'views'}, 'Index'])
, otrzymamy ciąg <a href="views">Index</a>
.
Ideą każdego DSL jest określenie kilku konstrukcji o określonej strukturze, która jest następnie przekazywana do funkcji. W tym przypadku strukturą tworzącą dsDSL jest ta tablica, która składa się z jednego do trzech elementów; te tablice mają specyficzną strukturę. Jeśli generate
dokładnie sprawdza swoje dane wejściowe (a jest to łatwe i ważne, aby dokładnie zweryfikować dane wejściowe, ponieważ te reguły sprawdzania poprawności są dokładnym odpowiednikiem składni DSL), powie dokładnie, gdzie popełniłeś błąd z danymi wejściowymi. Po pewnym czasie zaczniesz rozpoznawać, co odróżnia prawidłową strukturę w dsDSL, a struktura ta będzie bardzo sugestywna dla podstawowej rzeczy, którą generuje.
Teraz, jakie są zalety dsDSL w przeciwieństwie do DSL?
- DSDSL jest integralną częścią twojego kodu. Prowadzi to do mniejszej liczby wierszy, liczby plików i ogólnej redukcji narzutu.
- DSDSL są łatwe do przeanalizowania (stąd łatwiejsze do implementacji i modyfikacji). Parsowanie to po prostu iteracja przez elementy tablicy lub obiektu. Podobnie, dsDSL są stosunkowo łatwe do zaprojektowania, ponieważ zamiast tworzyć nową składnię (której wszyscy będą nienawidzić), możesz trzymać się składni swojego języka programowania (której wszyscy nienawidzą, ale przynajmniej już ją znają).
- DSDSL ma całą moc języka programowania. Oznacza to, że dsDSL, gdy jest właściwie stosowany, ma zaletę zarówno narzędzia wysokiego, jak i niskiego poziomu.
Ostatnie twierdzenie jest mocne, więc resztę tej sekcji poświęcę na wspieranie go. Co rozumiem przez właściwie zatrudniony ? Aby zobaczyć to w akcji, rozważmy przykład, w którym chcemy skonstruować tabelę wyświetlającą informacje z tablicy o nazwie 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']} ]
W rzeczywistej aplikacji DATA
będą generowane dynamicznie z zapytania do bazy danych.
Ponadto mamy zmienną FILTER
, która po zainicjowaniu będzie tablicą z kategoriami, które chcemy wyświetlić.
Chcemy, aby nasz stół:
- Wyświetl nagłówki tabeli.
- Dla każdego produktu pokaż pola: opis, cena i kategorie.
- Nie drukuj pola
id
, ale dodaj je jako atrybutid
dla każdego wiersza. WERSJA ALTERNATYWNA: Dodaj atrybutid
do każdego elementutr
. - Umieść klasę
onSale
, jeśli produkt jest na wyprzedaży. - Sortuj produkty według ceny malejącej.
- Filtruj niektóre produkty według kategorii. Jeśli
FILTER
jest pustą tablicą, wyświetlimy wszystkie produkty. W przeciwnym razie wyświetlimy tylko te produkty, których kategoria produktu jest zawarta wFILTER
.
Możemy stworzyć logikę prezentacji, która odpowiada temu wymaganiu w ~20 liniach kodu:
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]]; })]; }) ]]; }
Przyznaję, że nie jest to prosty przykład, jednak przedstawia on dość prosty pogląd na cztery podstawowe funkcje pamięci trwałej, znane również jako CRUD. Każda nietrywialna aplikacja internetowa będzie miała bardziej złożone widoki.
Zobaczmy teraz, co robi ten kod. Po pierwsze, definiuje funkcję drawTable
, która zawiera logikę prezentacji rysowania tabeli produktów. Ta funkcja otrzymuje parametry DATA
i FILTER
, dzięki czemu może być używana do różnych zestawów danych i filtrów. drawTable
spełnia podwójną rolę częściowej i pomocniczej.
var drawTable = function (DATA, FILTER) {
Zmienna wewnętrzna printableFields
, to jedyne miejsce, w którym należy określić, które pola są drukowane, unikając powtórzeń i niespójności w obliczu zmieniających się wymagań.
var printableFields = ['description', 'price', 'categories'];
Następnie sortujemy DATA
według ceny produktów. Zauważ, że inne i bardziej złożone kryteria sortowania byłyby proste do zaimplementowania, ponieważ mamy do dyspozycji cały język programowania.
DATA.sort (function (a, b) {return a.price - b.price});
Tutaj zwracamy literał obiektowy; tablica zawierająca table
jako pierwszy element i zawartość jako drugi. To jest reprezentacja dsDSL <table>
, którą chcemy utworzyć.
return ['table', [
Tworzymy teraz wiersz z nagłówkami tabeli. Aby utworzyć jego zawartość, używamy dale.do, która jest funkcją podobną do Array.map, ale działa również dla obiektów. Będziemy iterować printableFields
i wygenerujemy nagłówki tabeli dla każdego z nich:
['tr', dale.do (printableFields, function (field) { return ['th', field]; })],
Zauważ, że właśnie zaimplementowaliśmy iterację, wół pociągowy generowania HTML, i nie potrzebowaliśmy żadnych konstrukcji DSL; potrzebowaliśmy tylko funkcji do iteracji struktury danych i zwracania dsDSL. Podobna natywna lub zaimplementowana przez użytkownika funkcja również sprawdziłaby się.
Teraz przejdź przez produkty zawarte w DATA
.
dale.do (DATA, function (product) {
Sprawdzamy, czy ten produkt został pominięty przez FILTER
. Jeśli FILTER
jest pusty, wydrukujemy produkt. Jeśli FILTER
nie jest pusty, będziemy iterować przez kategorie produktu, aż znajdziemy taki, który jest zawarty w FILTER
. Robimy to za pomocą dale.stop.
var matches = (! FILTER || FILTER.length === 0) || dale.stop (product.categories, true, function (category) { return FILTER.indexOf (category) !== -1; });
Zwróć uwagę na złożoność trybu warunkowego; jest dokładnie dostosowany do naszych wymagań i mamy całkowitą swobodę w wyrażaniu tego, ponieważ jesteśmy w języku programowania, a nie DSL.
Jeśli matches
to false
, zwracamy pustą tablicę (więc nie wyświetlamy tego produktu). W przeciwnym razie zwracamy <tr>
z jego właściwym identyfikatorem i klasą, a następnie iterujemy przez printableFields
, aby, no cóż, wydrukować pola.
return matches === false ? [] : ['tr', { id: product.id, class: product.onSale ? 'onsale' : undefined }, dale.do (printableFields, function (field) { return ['td', product [field]];
Oczywiście zamykamy wszystko, co otworzyliśmy. Czy składnia nie jest zabawna?
})]; }) ]]; }
Jak włączyć tę tabelę w szerszy kontekst? Piszemy funkcję o nazwie drawAll
, która będzie wywoływać wszystkie funkcje generujące widoki. Oprócz drawTable
możemy mieć również drawHeader
, drawFooter
i inne porównywalne funkcje, z których wszystkie zwrócą dsDSL .
var drawAll = function () { return generate ([ drawHeader (), drawTable (DATA, FILTER), drawFooter () ]); }
Jeśli nie podoba ci się powyższy kod, nic, co powiem, cię nie przekona. To jest dsDSL w najlepszym wydaniu . You might as well stop reading the article (and drop a mean comment too because you've earned the right to do so if you've made it this far!). 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.