Uczę się Scheme z SICP i mam wrażenie, że duża część tego, co czyni Scheme, a tym bardziej LISP, jest systemem makro. Ale skoro makra są rozszerzane w czasie kompilacji, dlaczego ludzie nie tworzą równoważnych systemów makr dla C / Python / Java / cokolwiek? Na przykład można powiązać python
polecenie z expand-macros | python
czymkolwiek. Kod nadal byłby przenośny dla osób, które nie korzystają z systemu makr, wystarczyło rozwinąć makra przed opublikowaniem kodu. Ale nie znam niczego takiego poza szablonami w C ++ / Haskell, które, jak sądzę, nie są takie same. Co z LISP, jeśli w ogóle, ułatwia wdrażanie makrosystemów?
21
Odpowiedzi:
Wielu Lispers powie ci, że tym, co wyróżnia Lisp, jest homoikoniczność , co oznacza, że składnia kodu jest reprezentowana przy użyciu tych samych struktur danych, co inne dane. Na przykład, oto prosta funkcja (wykorzystująca składnię schematu) do obliczania przeciwprostokątnej trójkąta prostokątnego o podanych długościach boków:
Teraz homoiconicity mówi, że powyższy kod jest w rzeczywistości reprezentowalny jako struktura danych (w szczególności listy list) w kodzie Lisp. Zastanów się zatem nad poniższymi listami i zobacz, jak się ze sobą „skleja”:
(define #2# #3#)
(hypot x y)
(sqrt #4#)
(+ #5# #6#)
(square x)
(square y)
Makra pozwalają traktować kod źródłowy tak: listy rzeczy. Każdy z tych 6 „podlist” zawierają zarówno odnośniki do innych list lub symboli (w tym np
define
,hypot
,x
,y
,sqrt
,+
,square
).Jak więc wykorzystać homoikoniczność do „rozbierania” składni i tworzenia makr? Oto prosty przykład. Zaimplementujmy
let
makro, które nazwiemymy-let
. Jako przypomnienie,powinien rozwinąć się w
Oto implementacja wykorzystująca makra „jawnej zmiany nazwy” makr † :
form
Parametr jest związany z rzeczywistej postaci, więc na naszym przykładzie byłoby to(my-let ((foo 1) (bar 2)) (+ foo bar))
. Przeanalizujmy przykład:cadr
chwyta((foo 1) (bar 2))
część formularza.Następnie pobieramy ciało z formularza.
cddr
chwyta((+ foo bar))
część formularza. (Zauważ, że jest to przeznaczone do przechwytywania wszystkich podformularzy po powiązaniu; więc jeśli formularz byłwtedy ciało byłoby
((debug foo) (debug bar) (+ foo bar))
).lambda
wyrażenie i wywołanie, używając zebranych powiązań i treści. Strzałka wsteczna nazywana jest „quasi-cytatem”, co oznacza, że wszystko w quasi-cytacie należy traktować jako dosłowne punkty odniesienia, z wyjątkiem bitów po przecinkach („bez cudzysłowu”).(rename 'lambda)
użycialambda
powiązania obowiązującego, gdy to makro jest zdefiniowane , a nie jakiekolwieklambda
wiązanie, które może być w pobliżu, gdy to makro jest używane . (Jest to znane jako higiena ).(map car bindings)
zwraca(foo bar)
: pierwszy układ odniesienia w każdym powiązaniu.(map cadr bindings)
zwraca(1 2)
: drugi punkt odniesienia w każdym powiązaniu.,@
wykonuje „splicing”, który jest używany dla wyrażeń zwracających listę: powoduje wklejanie elementów listy do wyniku, a nie samej listy.(($lambda (foo bar) (+ foo bar)) 1 2)
, w której$lambda
tutaj odnosi się do przemianowanejlambda
.Prosto, prawda? ;-) (Jeśli nie jest to dla ciebie proste, wyobraź sobie, jak trudno byłoby wdrożyć system makr dla innych języków).
Tak więc możesz mieć systemy makr dla innych języków, jeśli masz możliwość „rozbierania” kodu źródłowego w niekłopotliwy sposób. Istnieją pewne próby tego. Na przykład sweet.js robi to dla JavaScript.
† Dla doświadczonych Schemerów, którzy to czytają, celowo wybrałem użycie jawnej zmiany nazw makr jako środkowego kompromisu między
defmacro
s używanymi przez inne dialekty Lisp isyntax-rules
(co byłoby standardowym sposobem implementacji takiego makra w Schemacie). Nie chcę pisać w innych dialektach Lisp, ale nie chcę zrazić obcokrajowców, którzy nie są przyzwyczajenisyntax-rules
.Dla porównania, oto
my-let
makro, które używasyntax-rules
:Odpowiednia
syntax-case
wersja wygląda bardzo podobnie:Różnica między nimi jest taka, że wszystko w
syntax-rules
ma niejawna#'
Stosowanej, więc można tylko mieć pary wzór / szablon wsyntax-rules
, a więc jest to całkowicie deklaratywny. Natomiast wsyntax-case
, bit po wzorcu jest rzeczywistym kodem, który ostatecznie musi zwrócić obiekt składni (#'(...)
), ale może również zawierać inny kod.źródło
syntax-rules
, który jest czysto deklaratywny. W przypadku skomplikowanych makr mogę użyćsyntax-case
, który jest częściowo deklaratywny, a częściowo proceduralny. A potem jest wyraźna zmiana nazwy, która jest czysto proceduralna. (Większość wdrożeń schematu zapewni jedensyntax-case
lub ER. Nie widziałem takiego, który zapewnia oba. Są równoważne pod względem mocy.)Zdanie odrębne: homoikoniczność Lisp jest o wiele mniej przydatna niż większość fanów Lisp chciałaby w to uwierzyć.
Aby zrozumieć makra składniowe, ważne jest, aby zrozumieć kompilatory. Kompilatorem jest przekształcanie kodu czytelnego dla człowieka w kod wykonywalny. Z bardzo wysokiego poziomu ma to dwie ogólne fazy: parsowanie i generowanie kodu .
Parsowanie to proces odczytu kodu, interpretowania go zgodnie z zestawem reguł formalnych i przekształcania go w strukturę drzewa, ogólnie znaną jako AST (abstrakcyjne drzewo składniowe). Mimo całej różnorodności języków programowania jest to jedna niezwykła wspólność: zasadniczo każdy język programowania ogólnego przeznaczenia analizuje strukturę drzewa.
Generowanie kodu bierze AST parsera jako dane wejściowe i przekształca go w kod wykonywalny poprzez zastosowanie formalnych reguł. Z punktu widzenia wydajności jest to znacznie prostsze zadanie; wiele kompilatorów języka wysokiego poziomu spędza 75% lub więcej czasu na analizie.
Należy pamiętać o Lisp, ponieważ jest bardzo, bardzo stary. Wśród języków programowania tylko FORTRAN jest starszy niż Lisp. Dawno temu parsowanie (powolna część kompilacji) było uważane za mroczną i tajemniczą sztukę. Oryginalne artykuły Johna McCarthy'ego na temat teorii Lispa (kiedy był to tylko pomysł, którego nigdy nie myślał, że można go zaimplementować jako prawdziwy język programowania komputerowego) opisują nieco bardziej złożoną i ekspresyjną składnię niż współczesne „wyrażenia S wszędzie na wszystko " notacja. Stało się to później, kiedy ludzie próbowali to wdrożyć. Ponieważ parsowanie nie było wówczas dobrze zrozumiałe, po prostu spreparowali go i wrzucili składnię do homoikonicznej struktury drzewa, aby zadanie parsera było całkowicie trywialne. Rezultat końcowy jest taki, że ty (programista) musisz wykonać dużo parsera ” s pracuj nad tym, pisząc formalny kod AST bezpośrednio w kodzie. Homoikoniczność „nie sprawia, że makra są o wiele łatwiejsze”, ponieważ sprawia, że pisanie wszystkiego innego jest o wiele trudniejsze!
Problem polega na tym, że szczególnie w przypadku dynamicznego pisania wyrażenia S bardzo trudno przenoszą ze sobą wiele informacji semantycznych. Gdy cała składnia jest tego samego typu (listy list), składnia nie ma zbyt wiele kontekstu, a więc system makr ma bardzo niewiele do pracy.
Teoria kompilatorów przeszła długą drogę od lat 60., kiedy wynaleziono Lisp, i chociaż rzeczy, które udało się osiągnąć, były imponujące jak na swój dzień, teraz wyglądają raczej prymitywnie. Na przykład nowoczesnego systemu metaprogramowania przyjrzyj się (niestety niedocenionemu) językowi Boo. Boo jest typem statycznym, obiektowym i otwartym, więc każdy węzeł AST ma typ o dobrze zdefiniowanej strukturze, do którego programista makr może odczytać kod. Język ma stosunkowo prostą składnię zainspirowaną Pythonem, z różnymi słowami kluczowymi, które nadają wewnętrzne znaczenie semantyczne zbudowanym z nich strukturom drzewa, a jego metaprogramowanie ma intuicyjną składnię quasi-cytatową, aby uprościć tworzenie nowych węzłów AST.
Oto makro, które utworzyłem wczoraj, kiedy zdałem sobie sprawę, że stosuję ten sam wzorzec do wielu różnych miejsc w kodzie GUI, gdzie wywoływałbym
BeginUpdate()
formant interfejsu użytkownika, wykonał aktualizację wtry
bloku, a następnie wywołałEndUpdate()
:macro
Polecenia jest, w rzeczywistości, samą w sobie makro , który zajmuje makro ciała, oraz generujący klasę przetwarzać makro. Używa nazwy makra jako zmiennej, któraMacroStatement
zastępuje węzeł AST reprezentujący wywołanie makra. [| ... |] jest blokiem quasi-cytatu, generującym AST, który odpowiada kodowi wewnątrz, a wewnątrz bloku quasi-cytatu symbol $ zapewnia funkcję „quote-quote”, zastępując w węźle, jak określono.Dzięki temu można napisać:
i rozwinąć do:
Wyrażenie makra w ten sposób jest prostsze i bardziej intuicyjne niż w makrze Lisp, ponieważ programista zna strukturę
MacroStatement
i wie, jak działająArguments
iBody
właściwości, a tę wiedzę można wykorzystać do wyrażenia pojęć związanych z bardzo intuicyjną droga. Jest to również bezpieczniejsze, ponieważ kompilator zna strukturęMacroStatement
, a jeśli spróbujesz zakodować coś, co nie jest poprawne dlaMacroStatement
, kompilator natychmiast go złapie i zgłosi błąd zamiast nie wiedzieć, dopóki coś się nie pojawi środowisko uruchomieniowe.Przeszczepianie makr na Haskell, Python, Java, Scala itp. Nie jest trudne, ponieważ te języki nie są homoikoniczne; jest to trudne, ponieważ języki nie są dla nich zaprojektowane, i działa najlepiej, gdy hierarchia AST twojego języka jest projektowana od podstaw, aby była badana i obsługiwana przez system makr. Kiedy pracujesz z językiem, który został zaprojektowany z myślą o metaprogramowaniu od samego początku, makra są znacznie prostsze i łatwiejsze w obsłudze!
źródło
if...
na przykład nie wygląda jak wywołanie funkcji. Nie znam Boo, ale wyobraź sobie, że Boo nie ma dopasowania wzorca, czy mógłbyś wprowadzić go z własną składnią jako makro? Chodzi mi o to - każde nowe makro w Lisp wydaje się w 100% naturalne, w innych językach działają, ale widać szwy.Jak to? Cały kod w SICP jest napisany w stylu wolnym od makr. W SICP nie ma makr. Tylko w przypisie na stronie 373 są wspomniane makra.
Niekoniecznie są. Lisp zapewnia makra zarówno w tłumaczach, jak i kompilatorach. Dlatego może nie być czasu kompilacji. Jeśli masz interpreter Lisp, makra są rozwijane w czasie wykonywania. Ponieważ wiele systemów Lisp ma wbudowany kompilator, można wygenerować kod i skompilować go w czasie wykonywania.
Przetestujmy to za pomocą SBCL, wspólnej implementacji Lisp.
Przełączmy SBCL na tłumacza:
Teraz definiujemy makro. Makro drukuje coś, gdy wywoływane jest rozszerzenie kodu. Wygenerowany kod nie jest drukowany.
Teraz użyjmy makra:
Widzieć. W powyższym przypadku Lisp nic nie robi. Makro nie jest rozwijane w czasie definicji.
Ale w czasie wykonywania, gdy używany jest kod, makro jest rozwijane.
Ponownie, w czasie wykonywania, gdy kod jest używany, makro jest rozwijane.
Zauważ, że SBCL rozwinąłby się tylko raz podczas korzystania z kompilatora. Ale różne implementacje Lisp zapewniają również tłumaczy - jak SBCL.
Dlaczego makra są łatwe w Lisp? Cóż, nie są naprawdę łatwe. Tylko w Lisps, a jest ich wiele, które mają wbudowaną obsługę makr. Ponieważ wiele Lisps jest wyposażonych w rozbudowaną maszynę do makr, wygląda na to, że jest to łatwe. Ale mechanizmy makro mogą być bardzo skomplikowane.
źródło
eval
lubload
kod w jakimkolwiek języku Lisp, makra w nich również zostaną przetworzone. Natomiast jeśli użyjesz systemu preprocesora, jak zaproponowano w pytaniu,eval
i tym podobne nie skorzystają z rozszerzenia makr.read
w Lisp. To rozróżnienie jest ważne, ponieważeval
działa na rzeczywistych strukturach danych listy (jak wspomniano w mojej odpowiedzi), a nie na formie tekstowej. Możesz więc użyć(eval '(+ 1 1))
i odzyskać 2, ale jeśli to zrobisz(eval "(+ 1 1)")
, odzyskasz"(+ 1 1)"
(ciąg). Użyćread
, aby uzyskać od"(+ 1 1)"
(ciąg znaków) do 7(+ 1 1)
(z listy jeden symbol i dwa fixnums).read
. Działają w czasie kompilacji w tym sensie, że jeśli masz kod podobny(and (test1) (test2))
, zostanie on rozszerzony(if (test1) (test2) #f)
(w schemacie) tylko raz, gdy kod zostanie załadowany, a nie za każdym razem, gdy kod zostanie uruchomiony, ale jeśli zrobisz coś podobnego(eval '(and (test1) (test2)))
, które odpowiednio skompilują (i makro rozwiną) to wyrażenie w czasie wykonywania.eval
działa tylko na ciągach tekstowych, a ich możliwości modyfikacji składni są o wiele bardziej niewyraźne i / lub kłopotliwe.Homoiconicity znacznie ułatwia wdrażanie makr. Idea, że kod to dane, a dane to kod, umożliwia mniej więcej (z wyjątkiem przypadkowego przechwytywania identyfikatorów, rozwiązanego za pomocą makr higienicznych ) dowolne zastępowanie jednego. Lisp i Scheme ułatwiają to dzięki składni wyrażeń S o jednolitej strukturze, a tym samym łatwej do przekształcenia w AST, które stanowią podstawę makr syntaktycznych .
Języki bez wyrażeń S lub homoikoniczności będą miały problemy z implementacją makr syntaktycznych, choć z pewnością można to zrobić. Projekt Kepler próbuje na przykład przedstawić je Scali.
Największym problemem związanym z używaniem makr składniowych oprócz niejednorodności jest kwestia arbitralnie generowanej składni. Oferują ogromną elastyczność i moc, ale za cenę, której kod źródłowy może już nie być tak łatwy do zrozumienia lub utrzymania.
źródło