Co z LISP, jeśli w ogóle, ułatwia wdrażanie makrosystemów?

21

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ć pythonpolecenie z expand-macros | pythonczymkolwiek. 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?

Elliot Gorochowski
źródło
3
„Kod nadal byłby przenośny dla osób, które nie korzystają z systemu makr, wystarczyło rozwinąć makra przed opublikowaniem kodu”. - żeby cię ostrzec, to raczej nie działa dobrze. Te inne osoby byłyby w stanie uruchomić kod, ale w praktyce kod z rozszerzeniem makr jest często trudny do zrozumienia i zazwyczaj trudny do zmodyfikowania. Jest to w rzeczywistości „źle napisane” w tym sensie, że autor nie dostosował rozszerzonego kodu dla ludzkich oczu, ale dostosował prawdziwe źródło. Spróbuj powiedzieć programistowi Java, że ​​uruchamiasz swój kod Java przez preprocesor C i obserwuj, jaki kolor zmieniają ;-)
Steve Jessop
1
Makra muszą się jednak uruchomić, w tym momencie piszesz już tłumacza języka.
Mehrdad

Odpowiedzi:

29

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:

(define (hypot x y)
  (sqrt (+ (square x) (square y))))

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”:

  1. (define #2# #3#)
  2. (hypot x y)
  3. (sqrt #4#)
  4. (+ #5# #6#)
  5. (square x)
  6. (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 letmakro, które nazwiemy my-let. Jako przypomnienie,

(my-let ((foo 1)
         (bar 2))
  (+ foo bar))

powinien rozwinąć się w

((lambda (foo bar)
   (+ foo bar))
 1 2)

Oto implementacja wykorzystująca makra „jawnej zmiany nazwy” makr :

(define-syntax my-let
  (er-macro-transformer
    (lambda (form rename compare)
      (define bindings (cadr form))
      (define body (cddr form))
      `((,(rename 'lambda) ,(map car bindings)
          ,@body)
        ,@(map cadr bindings)))))

formParametr 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:

  1. Najpierw pobieramy powiązania z formularza. cadrchwyta ((foo 1) (bar 2))część formularza.
  2. Następnie pobieramy ciało z formularza. cddrchwyta ((+ foo bar))część formularza. (Zauważ, że jest to przeznaczone do przechwytywania wszystkich podformularzy po powiązaniu; więc jeśli formularz był

    (my-let ((foo 1)
             (bar 2))
      (debug foo)
      (debug bar)
      (+ foo bar))
    

    wtedy ciało byłoby ((debug foo) (debug bar) (+ foo bar))).

  3. Teraz budujemy wynikowe lambdawyraż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”).
    • Sposób (rename 'lambda)użycia lambdapowiązania obowiązującego, gdy to makro jest zdefiniowane , a nie jakiekolwiek lambdawią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.
  4. Łącząc to wszystko, otrzymujemy w rezultacie listę (($lambda (foo bar) (+ foo bar)) 1 2), w której $lambdatutaj odnosi się do przemianowanej lambda.

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 defmacros używanymi przez inne dialekty Lisp i syntax-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ą przyzwyczajeni syntax-rules.

Dla porównania, oto my-letmakro, które używa syntax-rules:

(define-syntax my-let
  (syntax-rules ()
    ((my-let ((id val) ...)
       body ...)
     ((lambda (id ...)
        body ...)
      val ...))))

Odpowiednia syntax-casewersja wygląda bardzo podobnie:

(define-syntax my-let
  (lambda (stx)
    (syntax-case stx ()
      ((_ ((id val) ...)
         body ...)
       #'((lambda (id ...)
            body ...)
          val ...)))))

Różnica między nimi jest taka, że wszystko w syntax-rulesma niejawna #'Stosowanej, więc można tylko mieć pary wzór / szablon w syntax-rules, a więc jest to całkowicie deklaratywny. Natomiast w syntax-case, bit po wzorcu jest rzeczywistym kodem, który ostatecznie musi zwrócić obiekt składni ( #'(...)), ale może również zawierać inny kod.

Chris Jester-Young
źródło
2
Zaleta, o której nie wspomniałeś: tak, są próby w innych językach, takich jak sweet.js dla JS. Jednak w seplenie pisanie makra odbywa się w tym samym języku, co pisanie funkcji.
Florian Margaine
Racja, możesz pisać procedury (w przeciwieństwie do deklaratywnych) makr w językach Lisp, co pozwala ci robić naprawdę zaawansowane rzeczy. BTW, właśnie to lubię w makrosystemach Scheme: jest wiele do wyboru. W przypadku prostych makr używam 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 jeden syntax-caselub ER. Nie widziałem takiego, który zapewnia oba. Są równoważne pod względem mocy.)
Chris Jester-Young
Dlaczego makra muszą modyfikować AST? Dlaczego nie mogą pracować na wyższym poziomie?
Elliot Gorokhovsky
1
Dlaczego więc LISP jest lepszy? Co wyróżnia LISP? Jeśli można zaimplementować makra w js, z pewnością można je zaimplementować również w innym języku.
Elliot Gorokhovsky
3
@ RenéG, jak powiedziałem w pierwszym komentarzu, dużą zaletą jest to, że nadal piszesz w tym samym języku.
Florian Margaine
23

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ę w trybloku, a następnie wywołał EndUpdate():

macro UIUpdate(value as Expression):
    return [|
        $value.BeginUpdate()
        try:
            $(UIUpdate.Body)
        ensure:
            $value.EndUpdate()
    |]

macroPolecenia 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óra MacroStatementzastę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ć:

UIUpdate myComboBox:
   LoadDataInto(myComboBox)
   myComboBox.SelectedIndex = 0

i rozwinąć do:

myComboBox.BeginUpdate()
try:
   LoadDataInto(myComboBox)
   myComboBox.SelectedIndex = 0
ensure:
   myComboBox.EndUpdate()

Wyrażenie makra w ten sposób jest prostsze i bardziej intuicyjne niż w makrze Lisp, ponieważ programista zna strukturę MacroStatementi wie, jak działają Argumentsi Bodywł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 dla MacroStatement, 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!

Mason Wheeler
źródło
4
Radość z czytania, dziękuję! Czy makra inne niż Lisp rozciągają się aż do zmiany składni? Ponieważ jedną z zalet Lisp jest to, że składnia jest taka sama, dlatego łatwo jest dodać funkcję, instrukcję warunkową, cokolwiek, ponieważ wszystkie są takie same. Podczas gdy w językach innych niż Lisp jedna rzecz różni się od drugiej -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.
greenoldman
4
Historia, którą zawsze czytałem, jest nieco inna. Zaplanowano alternatywną składnię do wyrażenia s, ale prace nad nim zostały opóźnione, ponieważ programiści już zaczęli używać wyrażeń s i uznali je za wygodne. Tak więc prace nad nową składnią zostały w końcu zapomniane. Czy możesz przytoczyć źródło wskazujące na braki teorii kompilatora jako przyczynę użycia wyrażeń s? Także rodzina Lisp ewoluowała przez wiele dziesięcioleci (Schemat, Common Lisp, Clojure) i większość dialektów postanowiła trzymać się s-wyrażeń.
Giorgio
5
„prostsze i bardziej intuicyjne”: przepraszam, ale nie wiem jak. „Updating.Arguments [0]” nie ma znaczenia, wolę mieć nazwany argument i pozwolić kompilatorowi sprawdzić się, czy liczba argumentów się zgadza: pastebin.com/YtUf1FpG
zrzut rdzeniowy
8
„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”. Spodziewałem się poszukiwania i stosowania optymalizacji zajmujących większość czasu (ale nigdy nie napisałem prawdziwego kompilatora). Czy coś mi umyka?
Doval
5
Niestety twój przykład tego nie pokazuje. Jest prymitywny do wdrożenia w dowolnym Lisp z makrami. W rzeczywistości jest to jeden z najbardziej prymitywnych makr do wdrożenia. To sprawia, że ​​podejrzewam, że niewiele wiesz o makrach w Lisp. „Składnia Lisp utknęła w latach sześćdziesiątych”: w rzeczywistości systemy makro w Lisp poczyniły duży postęp od 1960 roku (w 1960 Lisp nawet nie miał makr!).
Rainer Joswig
3

Uczę się Scheme z SICP i mam wrażenie, że duża część tego, co czyni Scheme, a tym bardziej LISP, jest systemem makro.

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.

Ale ponieważ makra są rozszerzane w czasie kompilacji

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:

* (setf sb-ext:*evaluator-mode* :interpret)

:INTERPRET

Teraz definiujemy makro. Makro drukuje coś, gdy wywoływane jest rozszerzenie kodu. Wygenerowany kod nie jest drukowany.

* (defmacro my-and (a b)
    (print "macro my-and used")
    `(if ,a
         (if ,b t nil)
         nil))

Teraz użyjmy makra:

MY-AND
* (defun foo (a b) (my-and a b))

FOO

Widzieć. W powyższym przypadku Lisp nic nie robi. Makro nie jest rozwijane w czasie definicji.

* (foo t nil)

"macro my-and used"
NIL

Ale w czasie wykonywania, gdy używany jest kod, makro jest rozwijane.

* (foo t t)

"macro my-and used"
T

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.

Rainer Joswig
źródło
Dużo czytałem o Scheme w Internecie, a także czytałem SICP. Ponadto, czy wyrażenia Lisp nie są kompilowane przed ich interpretacją? Muszą przynajmniej zostać przeanalizowane. Myślę więc, że „czas kompilacji” powinien być „czasem analizy”.
Elliot Gorokhovsky
@ Uważam, że RenéG Rainer uważa, że ​​jeśli ty evallub loadkod w jakimkolwiek języku Lisp, makra w nich również zostaną przetworzone. Natomiast jeśli użyjesz systemu preprocesora, jak zaproponowano w pytaniu, evali tym podobne nie skorzystają z rozszerzenia makr.
Chris Jester-Young
@ RenéG Również „parsowanie” jest wywoływane readw Lisp. To rozróżnienie jest ważne, ponieważ evaldział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).
Chris Jester-Young
@ RenéG Mając to na uwadze, makra nie działają w czasie 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.
Chris Jester-Young
@ RenéG Homoiconicity pozwala na ewaluację języków Lisp na strukturach list zamiast w formie tekstowej, a także na przekształcenie tych struktur list (za pomocą makr) przed wykonaniem. Większość języków evaldziała tylko na ciągach tekstowych, a ich możliwości modyfikacji składni są o wiele bardziej niewyraźne i / lub kłopotliwe.
Chris Jester-Young
1

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.

Inżynier świata
źródło