1P by GN⁺ | ★ favorite | 댓글 1개
  • lisp-in-rs-macros는 Rust의 선언적 매크로만으로 동작하는 단순한 렉시컬 스코프 Lisp 인터프리터이며, lisp! 매크로가 코드를 컴파일 타임에 평가해 문자열화된 Lisp 값을 생성함
  • lisp!(CAR (CONS (QUOTE A) (QUOTE (B))))는 rustc의 매크로 확장 과정에서 계산되어 문자열 "A"로 확장되며, 전체 구현은 250줄 미만
  • 예제는 CAR, LIST, QUOTE, PROGN, DEFINE, LAMBDA, DISPLAY를 사용하며, quine 예제는 Lisp 코드가 자기 자신으로 평가되는 형태를 보여줌
  • 명시적 재귀는 현재 지원하지 않지만, self application으로 리스트 append 같은 재귀 동작을 작성할 수 있음; 다만 DEFINE 자체는 재귀 정의를 처리하지 않음
  • 메타서큘러 인터프리터 예제는 동작하는 것으로 보이지만, ((lambda (X) X) (quote a)) 평가가 30초 이상 걸리고 백만 개 이상의 토큰을 생성해 cargo가 sigkill될 정도로 비효율적임

Rust 매크로 안에서 실행되는 Lisp

  • lisp-in-rs-macros는 Rust의 선언적 매크로만으로 작성된 렉시컬 스코프 Lisp 인터프리터임
  • lisp! 매크로는 전달된 Lisp 코드를 평가한 뒤, 계산된 Lisp 값을 문자열화함
  • 예를 들어 lisp!(CAR (CONS (QUOTE A) (QUOTE (B))))는 문자열 "A"로 확장됨
  • 이 계산은 런타임이 아니라 rustc가 매크로를 확장하는 컴파일 타임에 일어남
  • 구현은 250줄 미만임

기본 사용 예시

  • CAR, LIST, QUOTE를 조합해 리스트의 첫 원소를 가져올 수 있음
let output = lisp!(CAR (LIST (QUOTE A) (QUOTE B) (QUOTE C)));
assert_eq!(output, "A");
  • 여러 식을 평가하려면 PROGN을 사용함
    • PROGN은 모든 식을 평가하고 마지막 식의 값을 반환함
  • DISPLAY는 인자를 먼저 평가한 뒤 println!("{}", stringify!(evaled_argument)) 형태로 확장해 토큰을 문자열화해 출력함
lisp!(PROGN
    (DEFINE message (LAMBDA () (QUOTE "hello there")))
    (DISPLAY (message))
    (DEFINE NOT (LAMBDA (X) (COND (X NIL) (TRUE TRUE))) )
    (DISPLAY (NOT NIL))
);
  • 위 예제는 "hello there""TRUE"를 출력함

자기 자신으로 평가되는 quine

  • quine 예제는 Lisp 코드가 자기 자신으로 평가되는 형태를 보여줌
lisp!
       ((LAMBDA (s) (LIST s (LIST (QUOTE QUOTE) s)))
       (QUOTE (LAMBDA (s) (LIST s (LIST (QUOTE QUOTE) s)))));
  • 이 코드는 다음과 같은 stringify! 호출로 확장됨
stringify!(((LAMBDA (s) (LIST s (LIST (QUOTE QUOTE) s)))
       (QUOTE (LAMBDA (s) (LIST s (LIST (QUOTE QUOTE) s))))));

재귀와 self application

  • 이 Lisp는 현재 명시적 재귀를 지원하지 않음
  • 명시적 재귀 없이도 lambda만으로 재귀적 동작을 만들 수 있음
  • 예제의 append 함수는 본문에서 append 이름을 직접 언급하지 않고, self 인자를 통해 자기 적용으로 재귀 호출을 수행함
lisp!(PROGN
(DEFINE append
    (LAMBDA (self X Y)
        (COND
            ((EQ X NIL) Y)
            (TRUE (CONS (CAR X) (self self (CDR X) Y)))
        )))
(append append (QUOTE (A B)) (QUOTE (C D)))

)
  • 이 코드는 "(A B C D)"를 결과로 만듦

사용상 제약

  • lisp! 매크로는 단일 식만 평가함
    • 여러 식은 (PROGN expr1 expr2 expr3)로 묶어야 함
  • 빈 리스트는 self-evaluating이 아님
    • 빈 리스트 값은 NIL 또는 (QUOTE ())로 얻을 수 있음
    • 빈 리스트는 유일한 falsy 객체임
  • dotted list는 지원하지 않음
    • CONS는 마지막 인자가 리스트라고 가정함
  • DEFINE은 어디서나 사용할 수 있고 빈 리스트로 평가되지만, 재귀는 지원하지 않음
  • TRUE는 함수가 아닌 atom 중 유일하게 self-evaluating임

지원하는 form

DEFINE
QUOTE
LAMBDA
LET
PROGN
CAR
CDR
CONS
LIST
EQ
ATOM
APPLY
  • DEFINE은 진짜 Lisp식 재귀 정의라기보다 Scheme의 내부 정의에 가까운 형태임

Lisp로 작성한 Lisp 인터프리터

  • 저장소에는 이 Lisp 위에서 작성한 메타서큘러 인터프리터 예제가 포함되어 있음
  • 예제는 두 인자용 Y2 조합자, CADR, CAAR, ASSOC, eval 등을 정의함
  • 인터프리터는 동작하는 것으로 보이지만, ((lambda (X) X) (quote a))를 평가하려고 하면 30초 이상 걸림
  • 해당 평가는 백만 개가 넘는 토큰을 생성하고, 결국 cargo가 sigkill될 정도로 커짐
  • 명시적 Y 조합자를 사용한 재귀는 여기서 특히 비효율적
  • 이를 고치기 위해 명시적 재귀 primitive를 추가해야 한다고 적고 있음
  • 메타서큘러 평가기 작성 walkthrough로 Paul Graham의 "Roots of Lisp"를 추천함

구현 방식과 참고 자료

  • 기술 설명은 EXPLANATION.md에 있음
  • 매크로는 본질적으로 SECD machine을 시뮬레이션함
    • SECD machine은 lambda calculus term을 평가하는 단순한 스택 기반 추상 기계임

참고 자료

  • Functional Programming: Application and Implementation by Peter Henderson
  • Ager, Mads Sig, et al. "A functional correspondence between evaluators and abstract machines."
  • The Implementation of Functional Programming Languages by Simon Peyton Jones
  • Matt Might의 Lisp 관련 블로그 글: https://matt.might.net

TODO

  • letrec 추가
  • 재귀적 define 추가

댓글과 토론

Hacker News 의견들
  • Greenspun의 열 번째 법칙이 또 나왔네: https://en.wikipedia.org/wiki/Greenspun%27s_tenth_rule

    • 이건 주된 목적이 Lisp 구현이 아닌 코드베이스에 관한 얘기라서, 여기에는 딱 맞지 않아 보임
    • 이 법칙의 좋은 예가 C++가 템플릿 언어 안에서 car/cdr를 빙하처럼 느린 속도로 재발견한다는 점임
      C++26이 되어서야 Args...[0]로 타입 이름 매개변수 팩의 car를 얻을 수 있게 됨
      왜 빈 매개변수 팩용 nilcar/cdr 함수를 도입하고, 지금 같은 문법 난장판 대신 매개변수 팩을 저장할 수 있게 하지 않는지 모르겠음
    • “충분히 복잡한 C나 Fortran 프로그램은 Common Lisp 절반을 임시방편으로, 비공식 명세로, 버그 많고 느리게 구현한 것을 포함한다”라는 문장이 딱 떠오름
    • “충분히 복잡한”이 뭘 뜻하는지 모르겠고, 정의가 별로임
  • 예전에 비슷한 걸 해봤는데, 대시가 들어간 심볼을 정의할 수 없는 문제가 있었음
    DEFINE MY-FN... 같은 게 안 됐는데, Rust가 대시에서 토큰을 쪼개기 때문이었음
    작은 차이지만 실제 Lisp 코드 조각을 그대로 붙여넣을 수 없고 전부 밑줄로 바꿔야 했음. 이 구현도 같은지 궁금함

    • 지금은 모든 원자가 Rust 식별자라고 가정하고 있음. 구현이 쉬워져서 $x:ident로 매칭할 수 있기 때문이고, 그래서 원자 안의 대시는 지원하지 않음
      대신 $x:ident $(- $y:ident)* 같은 식으로 매칭하면 될 것 같음. 몇몇 매크로 분기 세부는 바꿔야겠지만 가능해 보임
    • 문제 없어 보이는데? DEFINE MYᜭFN...은 잘 동작함
  • 매크로만 말고 Rust 기반으로 잘 지원되는 Lisp 구현이 있으면 좋겠음
    Rust 위에 만들면 메모리 안전성을 얼마나 유지하거나 잃게 될지 궁금함. 빌림 검사기를 제정신인 방식으로 활용하는 게 가능하긴 할까?

    • SBCL 같은 일부 Lisp 컴파일러는 더 광범위한 컴파일 시점 타입 검사도 가능하지만, 그 정보는 프로그래머가 제공해야 하고 보통 일상적인 점진 개발보다는 최적화 단계의 일부에 가까움
      Lisp는 보통 동적인 성격으로 정의되고, 실행 시점 타입 검사가 큰 부분을 차지함. 객체 관리 방식을 사전에 프로그래머가 신경 쓰게 만들면 그런 시스템에서 기대하는 자유도와 표현력에 충돌함
      대신 컴파일러 자체는 비교적 단순해질 수 있음. 추가 선언이 없는 일반 코드는 기본적으로 안전하고, CLISP 같은 바이트코드 가상 머신이나 하드웨어 타입 검사를 하는 Lisp 머신에서는 그런 선언을 무시해도 항상 안전할 수 있음
      SBCL은 코드를 꽤 빠르게 컴파일하고, 다른 구현은 더 빠르다고도 들었음. 반면 Rust 컴파일러는 젊은 프로그래머에게 스래싱이라는 개념을 소개해줄 가능성이 더 큼
      둘은 처음 보기와 달리 서로 양립하기 어려운 세계라고 봄. Lisp는 본질적으로 “The Right Thing” 철학의 대표 언어이고, C는 “Worse is Better” 언어임. Rust는 둘 다 아니며, 양쪽 철학의 나쁜 특성을 반영하는 새 이름이 필요할 만큼 전혀 다른 무언가 같음
      그렇다고 원 글을 깎아내리려는 건 아니고, 여전히 멋진 해킹임
    • Steel은 괜찮아 보임: https://github.com/mattwparas/steel
      다른 Lisp들도 있음(https://github.com/alilleybrinker/langs-in-rust). 다만 덜 활발하게 유지되는 것 같음
  • 만들면서 재밌었고, rust-analyser가 수백만 개 토큰을 생성하는 매크로를 처리하지 못한다는 것도 배웠음

  • 다들 “재밌다”고 환호해야 하는 분위기지만, 이런 걸 볼 때마다 Rust에서 구현 가능하다는 사실이 싫어짐
    Rust는 원래도 단순한 언어는 아니었지만, 처음보다 훨씬 감당하기 어려운 형태가 된 것 같음

    • Rust가 단순한 언어가 아니라는 데는 동의함
      다만 이게 가능하다는 사실을 왜 싫어하는지는 잘 이해가 안 됨. 매크로 시스템이 거의 무한히 복잡한 코드를 생성할 수는 있지만, 매크로로 샌드박스된 Lisp를 구현하는 게 Rust가 초기보다 관리하기 어려워졌다는 강한 예시인지는 모르겠음
      한편 Rust의 타입 시스템이 C++ 템플릿이나 Haskell 타입 시스템처럼 튜링 완전하다는 점 때문에, 그 방식으로 구현한 Lisp도 보고 싶어짐
    • 그 부분은 강하게 반대함. Rust 팀은 제약을 제거하고 기능을 더 직교적으로 만들어서 계속 언어를 쓰기 쉽게 만들고 있음
      대표적인 예가 비어휘적 수명, 반환 위치의 impl Trait, 비동기 트레이트임. 1.0 이전에는 특별 문법이 붙은 GC 참조도 내장돼 있었는데 그런 기능은 제거되기도 했음
    • 1.0 이후의 실질적인 큰 변화는 async뿐임. async 없이 살고 싶다면 전적으로 선택 가능하고, 언어의 완전히 선택적인 부분임
      단순성을 원칙으로 삼는 언어를 원한다면 Rust는 원래 그런 언어가 아니었고, 다른 선택지는 많음
    • 이런 게 가능해지는 데는 사실 아주 적은 것만 필요함. 단순하다고 여겨지는 C 매크로로도 할 수 있을 것 같음
      확인해 봤고, 내가 이 내기에서 이김: https://github.com/kchanqvq/CSP
    • 매크로는 항상 매우 강력하면서도 동시에 까다로운 것 아닌가? 매크로 쪽을 언어 복잡도에 포함하진 않겠음
      특히 매크로를 “작성”하는 쪽을 말하는데, 쓸 수도 있고 안 쓸 수도 있는 추가 기능에 가깝다고 봄
  • 와, 이게 macro_rules를 쓰네

  • 그런데 C++은 템플릿이 튜링 완전이라 제정신인 언어가 아니라고 했던 것 아닌가?

    • C++은 조금만 알아도 제정신인 언어가 아님. 적어도 Rust 매크로는 문자 그대로의 텍스트 치환은 아니니 빛을 향한 한 걸음임
    • 튜링 완전과 튜링 타르핏은 다름
      Rust 매크로 시스템이 어느 쪽인지는 모르겠음
    • C++ 템플릿으로 개발하는 건 지옥임. Rust에는 적어도 macro_expand가 있고, Rust 도구들이 잘 만들어졌다는 점이 큼
  • Carp도 빠질 수 없음. 빌림 검사를 사용하는 Lisp이고, Lisp계의 “Rust” 같은 존재임
    1: https://github.com/carp-lang/Carp