Bagaimana dengan LISP, jika ada, membuatnya lebih mudah untuk menerapkan sistem makro?

21

Saya belajar Skema dari SICP dan saya mendapat kesan bahwa sebagian besar dari apa yang membuat Skema dan, terlebih lagi, LISP khusus adalah sistem makro. Tetapi, karena makro diperluas pada waktu kompilasi, mengapa orang tidak membuat sistem makro yang setara untuk C / Python / Java / apa pun? Misalnya, seseorang dapat mengikat pythonperintah expand-macros | pythonatau apa pun. Kode masih akan portabel untuk orang-orang yang tidak menggunakan sistem makro, seseorang hanya akan memperluas makro sebelum menerbitkan kode. Tapi saya tidak tahu hal seperti itu kecuali template di C ++ / Haskell, yang saya kumpulkan tidak benar-benar sama. Bagaimana dengan LISP, jika ada, membuatnya lebih mudah untuk menerapkan sistem makro?

Elliot Gorokhovsky
sumber
3
"Kode masih akan portabel untuk orang-orang yang tidak menggunakan sistem makro, seseorang hanya akan memperluas makro sebelum menerbitkan kode." - hanya untuk memperingatkan Anda, ini cenderung tidak berfungsi dengan baik. Orang-orang lain akan dapat menjalankan kode, tetapi dalam praktiknya kode yang diperluas makro seringkali sulit untuk dipahami, dan biasanya sulit untuk dimodifikasi. Ini berlaku "ditulis dengan buruk" dalam arti bahwa penulis belum merancang kode diperluas untuk mata manusia, mereka merancang sumber yang sebenarnya. Coba beri tahu programmer Java Anda menjalankan kode Java Anda melalui preprocessor C dan perhatikan warnanya yang berubah ;-)
Steve Jessop
1
Macro perlu mengeksekusi, pada saat itu Anda sudah menulis penerjemah untuk bahasa tersebut.
Mehrdad

Jawaban:

29

Banyak Lispers akan memberi tahu Anda bahwa apa yang membuat Lisp istimewa adalah homoikonisitas , yang berarti bahwa sintaksis kode diwakili menggunakan struktur data yang sama dengan data lainnya. Misalnya, inilah fungsi sederhana (menggunakan sintaks skema) untuk menghitung sisi miring dari segitiga siku-siku dengan panjang sisi yang diberikan:

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

Sekarang, homoiconicity mengatakan bahwa kode di atas sebenarnya direpresentasikan sebagai struktur data (khususnya, daftar daftar) dalam kode Lisp. Jadi, pertimbangkan daftar berikut dan lihat bagaimana mereka "merekatkan":

  1. (define #2# #3#)
  2. (hypot x y)
  3. (sqrt #4#)
  4. (+ #5# #6#)
  5. (square x)
  6. (square y)

Macro memungkinkan Anda untuk memperlakukan kode sumber hanya seperti itu: daftar barang. Masing-masing 6 "sublists" berisi baik pointer ke daftar lain, atau untuk simbol (dalam contoh ini: define, hypot, x, y, sqrt, +, square).


Jadi, bagaimana kita bisa menggunakan homoiconicity untuk "memilih" sintaks dan membuat macro? Ini contoh sederhana. Mari kita implementasikan kembali letmakro, yang akan kita panggil my-let. Sebagai pengingat,

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

harus berkembang menjadi

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

Berikut ini implementasi dengan menggunakan skema "penggantian nama eksplisit" makro :

(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)))))

The formparameter terikat ke bentuk yang sebenarnya, jadi misalnya kita, itu akan menjadi (my-let ((foo 1) (bar 2)) (+ foo bar)). Jadi, mari kita bekerja melalui contoh:

  1. Pertama, kami mengambil binding dari formulir. cadrmeraih ((foo 1) (bar 2))bagian formulir.
  2. Kemudian, kami mengambil tubuh dari formulir. cddrmeraih ((+ foo bar))bagian formulir. (Perhatikan bahwa ini dimaksudkan untuk mengambil semua subformulir setelah penjilidan; jadi jika bentuknya

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

    maka tubuh akan menjadi ((debug foo) (debug bar) (+ foo bar)).)

  3. Sekarang, kita benar-benar membangun lambdaekspresi dan panggilan yang dihasilkan menggunakan binding dan body yang telah kita kumpulkan. Backtick disebut "quasiquote", yang berarti memperlakukan segala sesuatu di dalam quasiquote sebagai datum literal, kecuali bit setelah koma ("unquote").
    • The (rename 'lambda)sarana untuk menggunakan lambdamengikat berlaku ketika makro ini didefinisikan , daripada apa pun yang lambdamengikat mungkin sekitar ketika makro ini digunakan . (Ini dikenal sebagai kebersihan .)
    • (map car bindings)mengembalikan (foo bar): datum pertama di masing-masing binding.
    • (map cadr bindings)mengembalikan (1 2): datum kedua di masing-masing binding.
    • ,@ tidak "splicing", yang digunakan untuk ekspresi yang mengembalikan daftar: itu menyebabkan elemen daftar disisipkan ke dalam hasil, bukan daftar itu sendiri.
  4. Menyatukan semua itu, kita dapatkan, sebagai hasilnya, daftar (($lambda (foo bar) (+ foo bar)) 1 2), di mana di $lambdasini merujuk pada yang diganti nama lambda.

Mudah, bukan? ;-) (Jika tidak mudah bagi Anda, bayangkan betapa sulitnya menerapkan sistem makro untuk bahasa lain.)


Jadi, Anda dapat memiliki sistem makro untuk bahasa lain, jika Anda memiliki cara untuk dapat "memilih" kode sumber dengan cara yang tidak kikuk. Ada beberapa upaya untuk ini. Misalnya, sweet.js melakukan ini untuk JavaScript.

† Untuk Skema berpengalaman yang membaca ini, saya sengaja memilih untuk menggunakan makro penggantian nama eksplisit sebagai kompromi tengah antara defmacroyang digunakan oleh dialek Lisp lain, dan syntax-rules(yang akan menjadi cara standar untuk mengimplementasikan makro semacam itu dalam Skema). Saya tidak ingin menulis dalam dialek Lisp lain, tetapi saya tidak ingin mengasingkan non-Schemers yang tidak terbiasa syntax-rules.

Untuk referensi, inilah my-letmakro yang menggunakan syntax-rules:

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

Versi yang sesuai syntax-caseterlihat sangat mirip:

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

Perbedaan antara keduanya adalah bahwa semua yang ada di dalamnya syntax-rulestelah #'diterapkan secara implisit , jadi Anda hanya dapat memiliki pasangan pola / template syntax-rules, karenanya sepenuhnya deklaratif. Sebaliknya, dalam syntax-case, bit setelah pola adalah kode aktual yang, pada akhirnya, harus mengembalikan objek sintaks ( #'(...)), tetapi dapat mengandung kode lain juga.

Chris Jester-Young
sumber
2
Keuntungan yang belum Anda sebutkan: ya, ada upaya dalam bahasa lain, seperti sweet.js untuk JS. Namun, dalam lisps, menulis makro dilakukan dalam bahasa yang sama dengan menulis fungsi.
Florian Margaine
Benar, Anda dapat menulis macro prosedural (versus deklaratif) dalam bahasa Lisp, yang memungkinkan Anda melakukan hal-hal yang benar-benar canggih. BTW, inilah yang saya sukai tentang sistem makro Skema: ada banyak pilihan. Untuk makro sederhana, saya menggunakan syntax-rules, yang murni bersifat deklaratif. Untuk makro yang rumit, saya dapat menggunakan syntax-case, yang sebagian bersifat deklaratif dan sebagian prosedural. Dan kemudian ada penggantian nama eksplisit, yang murni prosedural. (Sebagian besar implementasi Skema akan memberikan syntax-caseER atau ER. Saya belum pernah melihat yang menyediakan keduanya. Keduanya berkekuatan setara.)
Chris Jester-Young
Mengapa makro harus memodifikasi AST? Mengapa mereka tidak bisa bekerja di level yang lebih tinggi?
Elliot Gorokhovsky
1
Jadi mengapa LISP lebih baik? Apa yang membuat LISP istimewa? Jika seseorang dapat mengimplementasikan makro dalam js, tentunya seseorang dapat mengimplementasikannya dalam bahasa lain juga.
Elliot Gorokhovsky
3
@ RenéG seperti yang saya katakan di komentar pertama saya, keuntungan besar adalah bahwa Anda masih menulis dalam bahasa yang sama.
Florian Margaine
23

Pendapat berbeda: Homoniklikat Lisp jauh lebih tidak berguna daripada yang Anda yakini.

Untuk memahami makro sintaksis, penting untuk memahami kompiler. Tugas kompiler adalah mengubah kode yang bisa dibaca manusia menjadi kode yang dapat dieksekusi. Dari perspektif tingkat yang sangat tinggi, ini memiliki dua fase keseluruhan: parsing dan pembuatan kode .

Parsing adalah proses membaca kode, menafsirkannya sesuai dengan seperangkat aturan formal, dan mengubahnya menjadi struktur pohon, umumnya dikenal sebagai AST (Abstract Syntax Tree). Untuk semua keragaman di antara bahasa pemrograman, ini adalah satu kesamaan yang luar biasa: pada dasarnya setiap bahasa pemrograman tujuan umum diurai menjadi struktur pohon.

Pembuatan kode mengambil AST parser sebagai inputnya, dan mengubahnya menjadi kode yang dapat dieksekusi melalui penerapan aturan formal. Dari perspektif kinerja, ini adalah tugas yang jauh lebih sederhana; banyak kompiler bahasa tingkat tinggi menghabiskan 75% atau lebih dari waktu mereka untuk parsing.

Yang perlu diingat tentang Lisp adalah bahwa itu sangat, sangat tua. Di antara bahasa pemrograman, hanya FORTRAN yang lebih tua dari Lisp. Jauh di masa lalu, penguraian (bagian kompilasi yang lambat) dianggap sebagai seni yang gelap dan misterius. Makalah asli John McCarthy tentang teori Lisp (ketika itu hanya sebuah ide yang dia tidak pernah berpikir dapat benar-benar diterapkan sebagai bahasa pemrograman komputer nyata) menggambarkan sintaksis yang agak lebih kompleks dan ekspresif daripada "ekspresi-S" modern di mana-mana untuk semuanya. "notasi. Itu terjadi kemudian, ketika orang mencoba untuk benar-benar mengimplementasikannya. Karena parsing tidak dipahami dengan baik pada saat itu, mereka pada dasarnya menyadapnya dan membodohi sintaksis ke dalam struktur pohon homoikonik untuk membuat pekerjaan parser sama sekali sepele. Hasil akhirnya adalah bahwa Anda (pengembang) harus melakukan banyak pengurai Bekerja untuk itu dengan menulis AST formal langsung ke kode Anda. Homoiconicity tidak "membuat makro jadi lebih mudah" sebanyak itu membuat menulis segala sesuatu yang jauh lebih sulit!

Masalah dengan ini adalah, terutama dengan pengetikan dinamis, sangat sulit bagi ekspresi-S untuk membawa banyak informasi semantik bersamanya. Ketika semua sintaks Anda adalah jenis yang sama (daftar daftar), tidak ada banyak cara konteks yang disediakan oleh sintaks, dan sistem makro memiliki sangat sedikit untuk dikerjakan.

Teori kompiler telah berjalan jauh sejak 1960-an ketika Lisp ditemukan, dan sementara hal-hal yang dikerjakannya mengesankan untuk zamannya, mereka terlihat agak primitif sekarang. Untuk contoh sistem pemrograman metamodern, lihatlah bahasa Boo (yang sayangnya kurang dihargai). Boo diketik secara statis, berorientasi objek, dan open-source, sehingga setiap node AST memiliki tipe dengan struktur yang terdefinisi dengan baik yang dapat dibaca oleh pengembang makro. Bahasa ini memiliki sintaksis yang relatif sederhana yang terinspirasi oleh Python, dengan berbagai kata kunci yang memberikan makna semantik intrinsik pada struktur pohon yang dibangun darinya, dan metaprogramming-nya memiliki sintaks kuasiquote yang intuitif untuk menyederhanakan pembuatan node AST baru.

Inilah makro yang saya buat kemarin ketika saya menyadari saya menerapkan pola yang sama ke banyak tempat yang berbeda dalam kode GUI, di mana saya akan memanggil BeginUpdate()kontrol UI, melakukan pembaruan di tryblok, dan kemudian memanggil EndUpdate():

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

The macroperintah ini, pada kenyataannya, makro itu sendiri , yang mengambil tubuh makro sebagai masukan dan menghasilkan kelas untuk memproses makro. Itu menggunakan nama makro sebagai variabel yang berdiri untuk MacroStatementnode AST yang mewakili permintaan makro. [| ... |] adalah blok kuasiquote, menghasilkan AST yang sesuai dengan kode di dalamnya, dan di dalam blok ququote, simbol $ menyediakan fasilitas "unquote", menggantikan dalam node seperti yang ditentukan.

Dengan ini, dimungkinkan untuk menulis:

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

dan mengembangkannya ke:

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

Mengekspresikan makro dengan cara ini lebih sederhana dan lebih intuitif daripada dalam makro Lisp, karena pengembang tahu struktur MacroStatementdan tahu cara kerja Argumentsdan Bodyproperti, dan bahwa pengetahuan yang melekat dapat digunakan untuk mengekspresikan konsep yang terlibat dalam sangat intuitif cara. Ini juga lebih aman, karena kompiler mengetahui strukturnya MacroStatement, dan jika Anda mencoba kode sesuatu yang tidak valid untuk MacroStatement, kompiler akan segera menangkapnya dan melaporkan kesalahan alih-alih Anda tidak tahu sampai sesuatu meledak pada Anda di runtime.

Mencangkokkan makro ke Haskell, Python, Java, Scala, dll. Tidak sulit karena bahasa ini bukan homoikonik; sulit karena bahasa tidak dirancang untuk mereka, dan itu berfungsi dengan baik ketika hierarki AST bahasa Anda dirancang dari bawah ke atas untuk diperiksa dan dimanipulasi oleh sistem makro. Saat Anda bekerja dengan bahasa yang dirancang dengan metaprogramming dari awal, makro jauh lebih sederhana dan lebih mudah untuk dikerjakan!

Mason Wheeler
sumber
4
Senang membaca, terima kasih! Apakah makro non-Lisp merentang sejauh mengubah sintaks? Karena salah satu kekuatan Lisp adalah sintaksnya sama, sehingga mudah untuk menambahkan fungsi, pernyataan kondisional, apa pun karena semuanya sama. Sementara dengan bahasa non-Lisp satu hal berbeda dari yang lain - if...tidak terlihat seperti pemanggilan fungsi misalnya. Saya tidak tahu Boo, tetapi bayangkan Boo tidak memiliki pencocokan pola, dapatkah Anda memperkenalkannya dengan sintaksnya sendiri sebagai makro? Maksud saya adalah - setiap makro baru di Lisp terasa 100% alami, dalam bahasa lain mereka bekerja, tetapi Anda dapat melihat jahitannya.
greenoldman
4
Kisah seperti yang selalu saya baca agak berbeda. Sintaks alternatif untuk ekspresi-s telah direncanakan tetapi bekerja di atasnya tertunda karena programmer sudah mulai menggunakan ekspresi-s dan merasa nyaman. Jadi pekerjaan pada sintaks baru akhirnya dilupakan. Bisakah Anda mengutip sumber yang menunjukkan kekurangan teori kompiler sebagai alasan untuk menggunakan ekspresi-s? Juga, keluarga Lisp terus berevolusi selama beberapa dekade (Skema, Common Lisp, Clojure) dan sebagian besar dialek memutuskan untuk berpegang pada ekspresi s.
Giorgio
5
"lebih sederhana dan lebih intuitif": maaf, tapi saya tidak mengerti caranya. "Memperbarui.Argumen [0]" tidak bermakna, saya lebih suka memiliki argumen bernama dan membiarkan kompiler memeriksa sendiri apakah jumlah argumen cocok: pastebin.com/YtUf1FpG
coredump
8
"Dari perspektif kinerja, ini adalah tugas yang jauh lebih sederhana; banyak kompiler bahasa tingkat tinggi menghabiskan 75% atau lebih dari waktu mereka untuk parsing." Saya akan berharap mencari dan menerapkan optimasi untuk mengambil sebagian besar waktu (tapi saya tidak pernah menulis kompiler nyata ). Apakah saya melewatkan sesuatu di sini?
Doval
5
Sayangnya teladan Anda tidak menunjukkan hal itu. Sangat primitif untuk mengimplementasikan dalam Lisp apa pun dengan makro. Sebenarnya ini adalah salah satu makro paling primitif untuk diimplementasikan. Ini membuat saya curiga bahwa Anda tidak tahu banyak tentang makro di Lisp. "Sintaks Lisp macet pada 1960-an": sebenarnya sistem makro di Lisp telah membuat banyak kemajuan sejak 1960 (Pada 1960 Lisp bahkan tidak punya makro!).
Rainer Joswig
3

Saya belajar Skema dari SICP dan saya mendapat kesan bahwa sebagian besar dari apa yang membuat Skema dan, terlebih lagi, LISP khusus adalah sistem makro.

Bagaimana? Semua kode dalam SICP ditulis dalam gaya bebas makro. Tidak ada makro di SICP. Hanya dalam catatan kaki di halaman 373 makro pernah disebutkan.

Tapi, karena makro diperluas pada waktu kompilasi

Mereka belum tentu. Lisp menyediakan makro dalam penerjemah dan kompiler. Jadi mungkin tidak ada waktu kompilasi. Jika Anda memiliki juru bahasa Lisp, makro diperluas pada waktu eksekusi. Karena banyak sistem Lisp memiliki kompiler di papan, seseorang dapat menghasilkan kode dan mengompilasinya saat runtime.

Mari kita uji menggunakan SBCL, implementasi Common Lisp.

Mari kita beralih SBCL ke Penerjemah:

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

:INTERPRET

Sekarang kita mendefinisikan makro. Makro mencetak sesuatu ketika dipanggil untuk kode diperluas. Kode yang dihasilkan tidak dicetak.

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

Sekarang mari kita gunakan makro:

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

FOO

Lihat. Dalam kasus di atas Lisp tidak melakukan apa pun. Makro tidak diperluas pada waktu definisi.

* (foo t nil)

"macro my-and used"
NIL

Tetapi saat runtime, ketika kode digunakan, makro diperluas.

* (foo t t)

"macro my-and used"
T

Sekali lagi, saat runtime, ketika kode digunakan, makro diperluas.

Perhatikan bahwa SBCL akan berkembang hanya sekali ketika menggunakan kompiler. Tetapi berbagai implementasi Lisp juga menyediakan interpreter - seperti SBCL.

Mengapa makro mudah di Lisp? Yah, itu tidak mudah. Hanya di Lisps, dan ada banyak, yang memiliki dukungan makro bawaan. Karena banyak Lisps datang dengan mesin yang luas untuk makro, sepertinya mudah. Tetapi mekanisme makro bisa sangat rumit.

Rainer Joswig
sumber
Saya telah membaca banyak tentang Skema di web serta membaca SICP. Juga, bukankah ekspresi Lisp dikompilasi sebelum ditafsirkan? Setidaknya mereka harus diurai. Jadi saya kira "waktu kompilasi" harus "waktu parse".
Elliot Gorokhovsky
@Regge Rainer, saya percaya, adalah bahwa jika Anda evalatau loadkode dalam bahasa Lisp apa pun, makro di dalamnya akan diproses juga. Sedangkan jika Anda menggunakan sistem preprosesor seperti yang diusulkan dalam pertanyaan Anda, evaldan sejenisnya tidak akan mendapat manfaat dari ekspansi makro.
Chris Jester-Young
@ RenéG Juga, "parse" dipanggil readdalam Lisp. Perbedaan ini penting, karena evalbekerja pada struktur data daftar aktual (seperti yang disebutkan dalam jawaban saya), bukan pada bentuk tekstual. Jadi Anda dapat menggunakan (eval '(+ 1 1))dan mendapatkan kembali 2, tetapi jika Anda (eval "(+ 1 1)"), Anda mendapatkan kembali "(+ 1 1)"(string). Anda gunakan readuntuk mendapatkan dari "(+ 1 1)"(string 7 karakter) ke (+ 1 1)(daftar dengan satu simbol dan dua fixnums).
Chris Jester-Young
@ RenéG Dengan pemahaman itu, makro tidak berfungsi pada readwaktu. Mereka bekerja pada waktu kompilasi dalam arti bahwa jika Anda memiliki kode seperti (and (test1) (test2)), itu akan diperluas menjadi (if (test1) (test2) #f)(dalam Skema) hanya sekali, ketika kode dimuat, daripada setiap kali kode dijalankan, tetapi jika Anda melakukan sesuatu seperti (eval '(and (test1) (test2))), yang akan mengkompilasi (dan memperluas makro) ekspresi itu dengan tepat, saat runtime.
Chris Jester-Young
@RomeG Homoiconicity adalah apa yang memungkinkan bahasa Lisp untuk mengevaluasi pada struktur daftar daripada bentuk tekstual, dan untuk struktur daftar yang akan diubah (melalui makro) sebelum eksekusi. Sebagian besar bahasa hanya evalberfungsi pada string teks, dan kemampuannya untuk modifikasi sintaksis jauh lebih tidak bersemangat dan / atau rumit.
Chris Jester-Young
1

Homoiconicity membuatnya lebih mudah untuk mengimplementasikan macro. Gagasan bahwa kode adalah data dan data adalah kode yang memungkinkan untuk lebih atau kurang (kecuali penangkapan pengenal yang disengaja, dipecahkan oleh makro higienis ) untuk secara bebas menggantikan satu dengan yang lainnya. Lisp dan Skema menjadikan ini lebih mudah dengan sintaksis ekspresi-S yang terstruktur secara seragam dan dengan demikian mudah diubah menjadi AST yang merupakan dasar dari Makro Sintaksis .

Bahasa tanpa S-Expressions atau Homoiconicity akan mengalami kesulitan dalam mengimplementasikan Syntactic Macros meskipun masih bisa dilakukan. Proyek Kepler sedang mencoba untuk memperkenalkan mereka ke Scala misalnya.

Masalah terbesar dengan penggunaan sintaks makro selain dari non-homoiconicity, adalah masalah sintaksis yang dihasilkan secara sewenang-wenang. Mereka menawarkan fleksibilitas dan kekuatan yang luar biasa tetapi dengan harga yang kode sumber Anda mungkin tidak lagi mudah dipahami atau dipelihara.

Insinyur Dunia
sumber