Paul Graham อธิบายว่า LISP เป็นจุดบรรจบกันของภาษาการเขียนโปรแกรมทั้งหมด ข้อสังเกตของเขาคือเมื่อภาษาเติบโต ภาษาโดยเฉลี่ยยังคงเลื่อนไปทาง LISP ต่อไป ดังนั้นการทำความเข้าใจ LISP คือการเข้าใจโมเดลพื้นฐานของการเขียนโปรแกรมสมัยใหม่

คนอื่นๆ โน้มน้าว LISP ว่าจำเป็นต่อการเป็นโปรแกรมเมอร์ที่ดีขึ้น เอริค เรย์มอนด์กล่าวไปไกลถึงขนาดที่กล่าวว่าการเข้าใจ LISP นั้นเป็น “ประสบการณ์การรู้แจ้งอันลึกซึ้ง”

เพื่อค้นหาความเข้าใจนี้ ฉันไปที่แหล่งที่มา บทความต้นฉบับของ John McCarthy เรื่อง "ฟังก์ชันแบบเรียกซ้ำของนิพจน์สัญลักษณ์และการคำนวณด้วยเครื่องจักร" ที่วางรากฐานสำหรับ LISP

เป็นรายงานเชิงสำรวจที่มีความหนาแน่นสูงซึ่งเขียนโดยอัจฉริยะสำหรับนักวิทยาศาสตร์คอมพิวเตอร์ยุคแรกๆ ไม่ใช่เอกสารประกอบที่เข้าใจง่ายซึ่งมีวัตถุประสงค์เพื่อเป็นแนวทางให้ผู้อื่นเข้าใจ LISP ฉันพยายามอ่านแต่ละประโยคก่อนที่จะสะดุดกับบทความเรื่อง The Roots of LISP ของ Paul Graham ความชัดเจนของเขาช่วยนำทางฉันไปสู่ความเข้าใจที่เข้าใจยากนั้น

แต่จนกระทั่งฉันเขียนบทความนี้ ฉันจึงเข้าใจภาษาและพลังของมันอย่างถ่องแท้ ฉันขอฝากไว้ที่นี่เพื่อใครก็ตามที่เดินไปตามเส้นทางที่คล้ายกันและยังคงพยายามทำความเข้าใจ

ตามความจริงของ Paul Graham ฉันจึงนำ LISP เวอร์ชันนี้ไปใช้ใน "Arc" คุณสามารถค้นหารหัสเต็มได้ ที่นี่

รายการนิพจน์

เดิมที John McCarthy ได้กำหนดนิพจน์สัญลักษณ์ (S-expressions) และนิพจน์เมตา (M-expressions) S-expressions จะต้องมีรายการสัญลักษณ์ ในขณะที่ M-expressions จะต้องแสดงถึงฟังก์ชัน

; S-expression
((ab . c) . d . nil)
; M-expression
eq[x; x]
; returns t

อย่างไรก็ตาม คอมพิวเตอร์ที่พวกเขาใช้ LISP ครั้งแรกไม่มีวงเล็บเหลี่ยม ดังนั้นทุกอย่างจึงเขียนในรูปแบบ S-expression ละเว้นจุดและถือว่า nil ที่ยุติรายการ

ดังนั้น M-expression ข้างต้นจึงกลายเป็น

(eq x x)
; returns t

ซึ่งกลายเป็นไวยากรณ์มาตรฐานสำหรับ LISP ทำให้ภาษามีความเหมือนกันทางวากยสัมพันธ์

ฟังก์ชันเบื้องต้น

มีฟังก์ชันพื้นฐานเพียงเล็กน้อยที่จำเป็นในการทำให้ LISP เป็นภาษาที่สมบูรณ์ ความซับซ้อนหลายอย่าง เช่น การจัดสรรหน่วยความจำและการรวบรวมขยะ ได้รับการจัดการโดยคอมไพเลอร์

การแนะนำไวยากรณ์ของ LISP โดยย่อมีประโยชน์เนื่องจากบางแง่มุมไม่เป็นไปตามสัญชาตญาณ โดยเฉพาะราคาและการประเมินจากภายในสู่ภายนอก

เครื่องหมายคำพูดเป็นสิ่งจำเป็นสำหรับ LISP เนื่องจากไม่มีการแยกข้อมูลและโค้ด ลำดับของอักขระสามารถตีความได้ว่าเป็นตัวแปรหรือค่าขึ้นอยู่กับบริบท อักขระที่ยกมาช่วยแก้ปัญหานี้ด้วยการบังคับให้ตีความค่าตามตัวอักษร

ด้วยความเข้าใจพื้นฐานนี้ คุณก็พร้อมสำหรับฟังก์ชันพื้นฐาน 5 ประการที่จำเป็นสำหรับ LISP

อะตอม

ตรวจสอบว่าองค์ประกอบเป็นสัญลักษณ์เดียวหรือไม่

(atom 'x)
; returns t
(atom '(a b))
; returns nil

eq

ตรวจสอบว่าสัญลักษณ์อะตอมสองอันเหมือนกันหรือไม่ ใน Arc เขียนเป็น is

(eq 'x 'x)
; returns t
(eq 'x 'y)
; returns nil
(eq '(a b) '(a b))
; (a b) is a list and cannot be evaluated by eq
; returns nil

รถ

หมายถึงเนื้อหาของการลงทะเบียนที่อยู่ มันจะส่งคืนไอเท็มแรกในรายการ ตราบใดที่มันไม่ใช่อะตอมมิก

(car '(x a))
; returns x
(car '((x a) y))
; returns (x a)

ซีดี

ย่อมาจากเนื้อหาของการลงทะเบียนการลด มันจะส่งคืนทุกอย่างหลังจากรายการแรกในรายการ

(cdr '(x a))
; returns a
(cdr '((x a) y))
; returns y
(cdr '((x a) (y b)))
; returns (y b)

ข้อเสีย

ใช้เพื่อสร้างรายการจากอะตอมหรือรายการอื่นๆ

(cons 'x 'a)
; returns (x . a)
; lists should typically end in nil
; so it is better to write (cons x (cons a nil))
; which returns (x . a . nil)
; and can be written as (x a)
(cons '(x a) 'y)
; returns ((x a) . y)
(cons '(x a) '(y b))
; returns ((x a) y b)

หน้าที่พื้นฐาน

ฟังก์ชันเหล่านี้เป็นแกนหลักของ "ฟังก์ชันสากล" ซึ่งเป็นจุดสิ้นสุดสูงสุดของการดำเนินการนี้

หากคุณมีปัญหาในการติดตามไวยากรณ์ ฉันขอแนะนำให้อ่าน "บทช่วยสอนส่วนโค้ง" ของ Paul Graham ก่อน

_โมฆะ

ประเมินว่านิพจน์ว่างเปล่าหรือไม่

(def _null (x)
  (is x nil))
(_null nil)
; returns t
(_null '(x a))
; returns nil

_และ

ประเมินว่าเงื่อนไขทั้งสองเป็นจริงหรือไม่ ใน Arc นั้น t แสดงถึงความจริง และ nil แสดงถึงเท็จ

(def _and (x y)
  (if (is x t) (is y t) t nil))
(_and 'x 'y)
; returns t
(_and 'x nil)
; returns nil

_ไม่

ประเมินว่าเงื่อนไขคือ nil หรือไม่

(def _not (x)
  (if (is x nil) t))
(_not nil)
; returns t
(_not 'x)
; returns nil

_caar, _cadr, _caddr, _cadar และ _caddar

เหล่านี้เป็นชวเลขสำหรับการรวมกันของ car และ cdr สิ่งเหล่านี้เกิดขึ้นบ่อยครั้งดังนั้นชวเลขทำให้โค้ดของคุณแห้ง

(def _caar (x)
  (car (car x)))
(def _cadr (x)
  (car (cdr x)))
(def _cadar (x)
  (car (cdr (car x))))
(def _caddr (x)
  (car (cdr (cdr x))))
(def _caddar (x)
  (car (cdr (cdr (car x)))))
(_cadr '(a b c d))
; returns b
(_caddr '(a b c d))
; returns c
(_cadar '((a b c d) (e f g)))
; returns b
(_caddar '((a b c d) (e f g)))
; returns c

_ผนวก

ช่วยให้คุณเข้าร่วมรายการ

(def _append (x y)
  (if (_null x) y (cons (car x) (_append (cdr x) y))))
(_append '(a b) '(c d))
; returns (a b c d)

_รายการ

วิธีนี้จะรักษาความสมบูรณ์ของรายการที่คุณส่งเป็นอาร์กิวเมนต์ และไม่จำเป็นต้องเพิ่ม cons เมื่อรวมสองอะตอม

(def _list (x y)
  (cons x (cons y nil)))
(_list 'a 'b)
; returns (a b)
(_list '(a b) '(c d))
; returns ((a b) (c d))

_คู่

รวมสองรายการเข้าด้วยกันโดยสร้างคู่ตามตำแหน่งของแต่ละองค์ประกอบ

(def _pair (x y)
  (if (_and (_null x) (_null y)) nil
      (_and (_not (atom x)) (_not (atom y)))
      (cons (_list (car x) (car y))
            (_pair (cdr x) (cdr y)))))
(_pair '(x y z) '(a b c))
; returns ((x a) (y b) (z c))

_รศ

รับค่าจากคู่คีย์-ค่า โดยที่อาร์กิวเมนต์แรกคือคีย์ และอาร์กิวเมนต์ที่สองคือรายการคู่

(def _assoc (x y)
  (if (is (caar y) x) (_cadar y)
    (_assoc x (cdr y))))
(_assoc 'y '((x a) (y b)))
; returns b
(_assoc 'x '((w (a b)) (x (c d)) (y (e f))))
; returns (c d)

ฟังก์ชั่นสากล

พลังที่แท้จริงของ LISP คือความสามารถในการประเมินตัวเองจากองค์ประกอบบางอย่าง เช่นเดียวกับที่ John McCarthy ทำ เราจะกำหนด _eval ซึ่งสามารถประเมิน LISP ใน LISP

นี่เป็นลักษณะที่น่าประหลาดใจและทรงพลังที่สุดของภาษา ด้วย 5 พื้นฐานและ 12 ฟังก์ชัน คุณจะมีองค์ประกอบหลักในการสร้างล่าม

(def _eval (e a)
  (if
    (atom e) (_assoc e a)
    (atom (car e)) (if
      (is (car e) 'quote) (_cadr e)
      (is (car e) 'atom)  (atom (_eval (_cadr  e) a))
      (is (car e) 'eq)    (is   (_eval (_cadr  e) a)
                                (_eval (_caddr e) a))
      (is (car e) 'car)   (car  (_eval (_cadr  e) a))
      (is (car e) 'cdr)   (cdr  (_eval (_cadr  e) a))
      (is (car e) 'cons)  (cons (_eval (_cadr  e) a)
                                (_eval (_caddr e) a))
      (is (car e) 'cond)  (_evcon (cdr e) a)
      (_eval (cons (_assoc (car e) a)
                   (cdr e))
             a))
    (is (caar e) 'label)
      (_eval (cons (_caddar e) (cdr e))
             (cons (_list (_cadar e) (car e)) a))
    (is (caar e) 'lambda)
      (_eval (_caddar e)
             (_append (_pair (_cadar e) (_evlis (cdr e) a))
                      a))))
(def _evcon (c a)
  (if (_eval (_caar c) a)
      (_eval (_cadar c) a)
      (_evcon (cdr c) a)))
(def _evlis (m a)
  (if (_null m) nil
      (cons (_eval  (car m) a)
            (_evlis (cdr m) a))))

เมื่อใช้ _eval ไวยากรณ์ของนิพจน์ที่มีอยู่จะเฉพาะเจาะจงกับล่าม เราไม่ได้เขียนด้วยภาษา Arc อีกต่อไป แต่เป็นภาษาใหม่โดยสิ้นเชิง รูปแบบดั้งเดิมของ LISP

แม้ว่าคุณจะติดตามไปแล้ว แต่ก็ยังมีหลายสิ่งที่ต้องแยกย่อยที่นี่ ดังนั้นเรามาก้าวผ่านมันไปกันเถอะ

การตีความฟังก์ชันเบื้องต้น

_eval ใช้ e เป็นนิพจน์ที่จะประเมิน และ a เป็นรายการคู่ที่ e จะอ้างอิง

หาก e เป็นอะตอมมิก _assoc จะถูกเรียกให้ส่งคืนค่าที่ตรงกับคีย์ e ใน a

(_eval 'x '((x a) (y b)))
; returns a
(_eval 'y '((x a) (y b)))
; returns b

ในกรณีของ quote สัญลักษณ์ที่ตามหลัง quote จะถูกส่งกลับตามตัวอักษร

(_eval '(quote x) nil)
; nil is needed because _eval requires two arguments
; returns x
(_eval '(quote (x a)) nil)
; returns (x a)

สำหรับฟังก์ชันพื้นฐานอื่นๆ e จะอยู่ในรูปแบบ (function key) โดยที่ key ถูกใช้เพื่อรับค่าจาก a ซึ่งจะถูกประเมินโดย function

การใช้ _eval ต่อไปนี้เทียบเท่ากับ (atom 'y) ที่ง่ายกว่ามาก แต่สิ่งสำคัญคือการทำความเข้าใจฟังก์ชัน _eval สังเกตว่า x ถูกใช้เพื่ออ้างอิงค่าในพารามิเตอร์ตัวที่สอง a อย่างไร

(_eval '(atom x) '((x y)))
; returns t
(_eval '(atom x) '((x (a b))))
; returns nil

สำหรับทุกฟังก์ชันพื้นฐานยกเว้น quote จะมีการเรียก _eval แบบเรียกซ้ำจนกว่าจะถึง _assoc หรือ quote

นี่คือขั้นตอนที่ _eval ใช้ในการประเมิน atom

(_eval '(atom x) '((x y)))
; (atom (_eval (_cadr e) a))
; (atom (_eval  x ((x y))))
; (atom (_assoc x ((x y))))
; (atom y)
; returns t

car และ cdr มีโครงสร้างที่คล้ายกันมากกับ atom เนื่องจากต้องมีการประเมินเพียงนิพจน์เดียวเท่านั้น

(_eval '(car x) '((x (a b c))))
; returns a
(_eval '(cdr x) '((x (a b c))))
; returns (b c)

cons และ eq มีสองนิพจน์ที่ต้องได้รับการประเมิน ดังนั้น a จึงต้องมีสองคู่

(_eval '(eq x y) '((x a) (y a)))
; returns t
(_eval '(cons x y) '((x a) (y b)))
; returns (a . b)

cond ใช้ฟังก์ชันใหม่ _evcon ซึ่งรับรายการคู่ที่มีรูปแบบ (condition expression) เมื่อพบเงื่อนไขที่แท้จริง นิพจน์นั้นจะถูกประเมิน

(def _evcon (c a)
  (if (_eval (_caar c) a)
      (_eval (_cadar c) a)
      (_evcon (cdr c) a)))
(_evcon '(((atom c1) a1) ((atom c2) a2) ((atom c3) a3))
        '((c1 (a b)) (a1 not_atom)
          (c2 (c d)) (a2 still_not_atom)
          (c3 e)     (a3 is_atom)))
; returns is_atom

นี่คือนิพจน์เดียวกันโดยใช้ _eval

(_eval '(cond ((atom c1) a1) ((atom c2) a2) ((atom c3) a3)) '((c1 (a b)) (a1 not_atom) (c2 (c d)) (a2 still_not_atom) (c3 e) (a3 is_atom))) ; returns is_atom

การตีความฟังก์ชัน label และ lambda

ถ้า e เป็นฟังก์ชันอะตอมมิกแต่ไม่ใช่ฟังก์ชันพื้นฐาน ฟังก์ชันนั้นจะต้องเป็นฟังก์ชัน label หรือ lambda ที่ผู้ใช้กำหนด

นิพจน์ lambda มีรูปแบบ (lambda (param) (expression) arg) โดยที่ arg จะถูกส่งผ่านไปยัง expression ถึง param

(_eval '((lambda (param)
           (cond ((atom param) (quote is_atom))
                 ((quote t)    (quote not_atom))))
          arg)
       '((arg (a b))))
; returns not_atom

โปรดทราบว่า (quote t) ถูกใช้ที่นี่เป็นเงื่อนไข else ที่ชัดเจน Arc จัดการกรณีเหล่านี้อย่างสวยงาม แต่เนื่องจากเราผูกพันกับกฎของล่าม เราจึงต้องใช้ไวยากรณ์นี้

ในระหว่างการประเมิน นิพจน์ lambda ข้างต้นจะกลายเป็น

(_eval '(cond ((atom param) (quote is_atom))
              ((quote t)    (quote not_atom)))
       '((param (a b)) (arg (a b))))

โปรดสังเกตว่าอาร์กิวเมนต์ถูกขยายให้มีคู่สำหรับ param อย่างไร ซึ่งใช้ฟังก์ชันเสริม _evlis ซึ่งสร้างรายการคู่แบบวนซ้ำจาก arg สำหรับแต่ละ param ใน lambda ซึ่งอนุญาตให้ lambda จัดการรายการพารามิเตอร์ใดๆ ได้

((lambda (p1...pn) e) a1...an) เป็นคำจำกัดความที่เป็นทางการ .

label อนุญาตให้เรียกใช้ฟังก์ชันตามชื่อ ซึ่งเป็นคุณลักษณะที่สำคัญที่สุดของภาษาการเขียนโปรแกรมใดๆ

ที่นี่ McCarthy กำหนด ff ว่าเป็นฟังก์ชันเพื่อส่งคืนอะตอมแรกในรายการ มันใช้การเรียกซ้ำที่มีป้ายกำกับ

(_eval '((label ff (lambda (x)
                     (cond ((atom x) x)
                           ((quote t) (ff (car x))))))
         y)
       '((y ((a b) c))))
; returns a

เมื่อ _eval พบ label มันจะเก็บฟังก์ชันนั้นไว้ใน a เพื่อนำไปใช้ในภายหลัง นอกจากนี้ยังจะเริ่มประเมินฟังก์ชัน lambda ที่กำหนดโดย label ด้วย ในระหว่างการประเมิน นิพจน์ข้างต้นจะกลายเป็น

(_eval '((lambda (x)
           (cond ((atom x) x)
                 ((quote t) (ff (car x)))))
         y)
       '((ff (label ff (lambda (x)
               (cond ((atom x) x)
                     ((quote t) (ff (car x)))))))
         (y ((a b) c))))

ดังที่ McCarthy กล่าวไว้ การประเมินแบบเต็มคือ “กิจกรรมที่เหมาะกับคอมพิวเตอร์อิเล็กทรอนิกส์มากกว่าผู้คน” ฉันเห็นด้วยและจะไม่แสดงรายการการประเมินทุกขั้นตอน

ลดความซับซ้อน _eval

การใช้ _eval ในรูปแบบดิบค่อนข้างละเอียด ดังนั้น McCarthy จึงกำหนด _apply เป็น wrapper เป็น _eval ซึ่งช่วยให้สำนวนสั้นลงและเข้าใจง่ายขึ้น

นี่จะใช้พารามิเตอร์สำหรับ _eval และล้อมไว้เช่น (quote (param)) นอกจากนี้ยังใช้อาร์กิวเมนต์กับฟังก์ชันโดยตรงอีกด้วย

(def _appq (m)
  (if (_null m) nil (cons (_list 'quote (car m))
                          (_appq (cdr m)))))
(def _apply (f args)
  (_eval (cons f (_appq args)) nil))

เมื่อใช้ฟังก์ชันนี้ ฟังก์ชัน ff สามารถเขียนเป็นได้

(_apply '(label ff (lambda (x)
                     (cond ((atom x) x)
                           ((quote t) (ff (car x))))))
        '(a b))

ซึ่งเรียก _eval เป็น

(_eval '((label ff (lambda (x)
                     (cond ((atom x) x)
                           ((quote t) (ff (car x))))))
          (quote a) (quote b))
       'nil)

_apply ใช้ได้กับทุกสิ่งที่คุณเขียนโดยใช้ _eval แต่จะมีประโยชน์ที่จะทำความเข้าใจ _eval ก่อน ก่อนที่จะเพิ่มเลเยอร์ของนามธรรมนี้

ซื้อกลับบ้าน

ความสามารถในการกำหนดภาษาใหม่และการตรวจสอบสถานะภายในทำให้ LISP เป็นภาษาที่ยอดเยี่ยมสำหรับการสำรวจและการทดลอง

ความมหัศจรรย์ของการรวบรวมและปฏิบัติการหายไปแล้ว คุณสามารถดูการประเมินทุกขั้นตอนได้ด้วยตัวเอง นั่นทำให้การฝึกสะดุดผ่านไวยากรณ์ที่เก่าแก่สมหวัง

ฉันไม่เห็นตัวเองใช้ LISP ในการผลิต อย่างไรก็ตาม ฉันจะใช้มันเป็นเครื่องมือในการขยายความเข้าใจเกี่ยวกับการเขียนโปรแกรมระดับต่ำต่อไป

ขั้นตอนต่อไปสำหรับฉันคือการทำความเข้าใจวิธีการใช้คอมไพเลอร์ที่จะแปลงสิ่งนี้เป็นรหัสเครื่อง ฉันวางแผนที่จะอ่าน "โครงสร้างและการตีความโปรแกรมคอมพิวเตอร์" เพื่ออ่าน

นอกจากนี้ ฉันต้องการปรับปรุงล่ามนี้ให้ทันสมัย ดังที่ Paul Graham เขียนว่า “ภาษาที่เขา [John McCarthy] เขียนในปี 1960 ขาดหายไปมาก ไม่มีผลข้างเคียง ไม่มีการดำเนินการตามลำดับ ไม่มีตัวเลขที่ใช้งานได้จริง และมีขอบเขตแบบไดนามิก” แต่สิ่งนี้สามารถแก้ไขได้

Paul Graham กล่าวถึงบทความของ Steele และ Sussman เรื่อง "The Art of the Interpreter" โดยไม่ได้เจาะจงเจาะจง บางทีฉันจะพูดถึงสิ่งเหล่านี้ในบทความอื่น

เมื่อเจาะลึกประวัติความเป็นมาของการเขียนโปรแกรม คุณจะพบอิทธิพลของ LISP ทุกที่ การฝึกปรับให้เข้ากับรูปแบบไวยากรณ์เป็นสิ่งที่ควรค่าแก่การแสวงหาในตัวมันเอง แต่การพัฒนาความเข้าใจที่แท้จริงนั้นจะเปิดหน้าต่างสู่การทำงานภายในของทุกภาษา นั่นคือจุดประสงค์ของการทำความเข้าใจ LISP

เผยแพร่ครั้งแรกที่ https://joshbradley.me เมื่อวันที่ 4 เมษายน 2020