Skip to content

Instantly share code, notes, and snippets.

@YangSiJun528
Last active September 8, 2025 07:38
Show Gist options
  • Select an option

  • Save YangSiJun528/1bc1afa21de2cc926766ea29f2de5898 to your computer and use it in GitHub Desktop.

Select an option

Save YangSiJun528/1bc1afa21de2cc926766ea29f2de5898 to your computer and use it in GitHub Desktop.
SCIP in JS 실습
// 2-17
function last_pair(items) {
return is_null(tail(items))
? head(items)
: last_pair(tail(items));
}
const odds = list(1, 3, 5, 7);
last_pair(odds);
const odds2 = list(1, 3, 5, 7, 9);
last_pair(odds2);
// 2-18
function reverse(items) {
function reverse_iter(items, rs) {
return is_null(items)
? rs
: reverse_iter(tail(items),
pair(head(items), rs));
}
return reverse_iter(items, null);
}
reverse(list(1, 4, 5, 9, 16, 25));
// 2-20
function plus_curried(x) {
return y => x + y;
}
// function brooks(curried, items) {
// return is_null(tail(items))
// ? curried(head(items))
// : brooks(curried(head(items)), tail(items));
// }
// 이게 좀 더 나은 이유가, 위 방식이면 체크를 한 번 더 할듯?
function brooks(curried, items) {
return is_null(items)
? curried
: brooks(curried(head(items)), tail(items));
}
brooks(plus_curried, list(3, 4));
function brooks_curried(items) {
return brooks(head(items), tail(items));
}
brooks_curried(list(plus_curried, 3, 4));
// 2-21
// function square_list(items) {
// return is_null(items)
// ? null
// : pair(head(items) * head(items), square_list(tail(items)));
// }
// square_list(list(1, 2, 3, 4));
function square_list(items) {
return map(x => x*x, items);
}
square_list(list(1, 2, 3, 4));
// 2-23
function for_each(fun, items) {
if (is_null(items)) {
return null;
}
else {
fun(head(items));
for_each(fun, tail(items));
}
return true;
}
for_each(x => display(x), list(57, 321, 88));
// 2-27
const x = list(list(1, 2), list(3, 4));
function deep_reverse(items) {
function reverse_iter(items, rs) {
return is_null(items)
? rs
: !is_pair(head(items))
? reverse_iter(tail(items), pair(head(items), rs)) // 그냥 값이면 그대로 붙임, 순서가 없는 상태일꺼니까
: reverse_iter(tail(items), pair(deep_reverse(head(items)), rs)); // pair면 재귀로 반대로 붙임
// 소트 머지 정렬이랑 알고리즘이 비슷함
}
return reverse_iter(items, null);
}
deep_reverse(x);
// 2-28
// const x = list(list(1, 2), list(3, 4));
function fringe(items) {
if (is_null(items)) {
// 리스트가 비었으면 null
return null;
} else if (!is_pair(items)) {
// 원자(leaf)면, 그 값을 하나짜리 리스트로
return pair(items, null);
} else {
// pair라면 head와 tail을 각각 fringe로 구해서 이어 붙인다
return append(fringe(head(items)), fringe(tail(items)));
}
}
fringe(x);
// 2-30
function square_tree(tree) {
return is_null(tree)
? null
: ! is_pair(tree)
? tree * tree
: pair(square_tree(head(tree)),square_tree(tail(tree)));
}
square_tree(list(1, list(2, list(3, 4), 5), list(6, 7)));
// function square_tree(tree) {
// return map(sub_tree => is_pair(sub_tree)
// ? square_tree(sub_tree)
// : sub_tree * sub_tree,
// tree);
// }
// square_tree(list(1, list(2, list(3, 4), 5), list(6, 7)));
// 2-31
function tree_map(func, tree) {
return is_null(tree)
? null
: ! is_pair(tree)
? func(tree)
: pair(tree_map(head(tree)),tree_map(tail(tree)));
}
function square_tree_map(tree) {
return tree_map(x => x*x, tree);
}
// 2-33
// 그냥 구현하고 답이랑 체크함, 중복구현이라 실행 안됨.
// function map(f, sequence) {
// return accumulate((x, y) => pair(f(x), y), null, sequence);
// }
// function append(seq1, seq2) {
// return accumulate(pair, seq2, seq1);
// }
// function length(sequence) {
// return accumulate((x, y) => y + 1, 0, sequence);
// }
// 2-35
// 포기, 어케 잘 하면 될거같긴 한데, 익명함수를 쓰면 푸는 방법이 생각이 안남.
// 2-38
// op가 실행 순서가 무관해야 함. 각 요소를 개별적으로 계산하는 map이나 그런건 상관없는데, pair같은건 fold-l, r 순서에 영향을 받음
// 결합 법칙(associativity)을 만족해야 함
// 2-39
const fold_right = accumulate;
function fold_left(op, initial, sequence) {
function iter(result, rest) {
return is_null(rest)
? result
: iter(op(result, head(rest)),
tail(rest));
}
return iter(initial, sequence);
}
// function reverse(sequence) {
// return fold_right((x, y) => append(y, pair(x, null)), null, sequence);
// }
// reverse(list(1, 4, 5, 9, 16, 25));
function reverse(sequence) {
return fold_left((x, y) => pair(y, x), null, sequence);
}
reverse(list(1, 4, 5, 9, 16, 25));
// 2-40
function square(x) {
return x * x;
}
function smallest_divisor(n) {
return find_divisor(n, 2);
}
function find_divisor(n, test_divisor) {
return square(test_divisor) > n
? n
: divides(test_divisor, n)
? test_divisor
: find_divisor(n, test_divisor + 1);
}
function divides(a, b) {
return b % a === 0;
}
function enumerate_interval(low, high) {
return low > high
? null
: pair(low,
enumerate_interval(low + 1, high));
}
function flatmap(f, seq) {
return accumulate(append, null, map(f, seq));
}
function unique_pairs(n) {
return flatmap(i => map(j => list(i, j),
enumerate_interval(1, i - 1)),
enumerate_interval(1, n));
}
unique_pairs(3);
function is_prime(n) {
return n === smallest_divisor(n);
}
function is_prime_sum(pair) {
return is_prime(head(pair) + head(tail(pair)));
}
function make_pair_sum(pair) {
return list(head(pair), head(tail(pair)),
head(pair) + head(tail(pair)));
}
function prime_sum_pairs(n) {
return map(make_pair_sum,
filter(is_prime_sum, unique_pairs(n)));
}
prime_sum_pairs(3);
// 2-54
// 뭔가 잘하면 더 줄어들거 같은데 흠...
function equal(l1, l2) {
return is_null(l1) && is_null(l2)
? true
: is_null(l1) || is_null(l2)
? false
: is_list(l1) && is_list(l2)
? equal(head(l1), head(l2)) && equal(tail(l1), tail(l2))
: is_list(l1) || is_list(l2)
? false
: l1 === l2;
}
equal(list("this", "is", "a", "list"), list("this", "is", "a", "list"));
//equal(list("this", "is", "a", "list"), list("this", list("is", "a"), "list"));

Ah, 네, 그 부분이 가장 혼란스러울 수 있는 부분입니다. 재귀 함수처럼 보이는 코드가 어떻게 "한 번만 분석"될 수 있는지 의아하시군요. 개념적으로 이해가 되는데 코드 수준에서 어떻게 동작하는지 보고 싶다는 말씀에, 팩토리얼 함수의 정의와 호출 과정을 단계별로 자세히 설명해드리겠습니다.

팩토리얼 함수의 생애 주기 (Lifecycle)

1. 정의 단계 (Definition Phase - 한 번만 발생)

프로그램이 factorial 함수를 정의하는 문장을 만납니다.

function factorial(n) {
    return n === 1 ? 1 : factorial(n - 1) * n;
}

이 시점에서 analyze 함수가 함수 본문을 분석하기 시작합니다.

  • 본문 분석: return n === 1 ? 1 : factorial(n - 1) * n; 이라는 표현식을 분석합니다.

  • 조건문 분석: 이 표현식은 조건문이므로 analyze_conditional이 호출됩니다.

  • 조건부 분석:

    • 조건 (n === 1): analyze -> analyze_application이 호출되어 n === 1을 실행하는 함수(pfun)를 생성합니다.
    • 결과 (1): analyze -> analyze_literal이 호출되어 1을 반환하는 함수(cfun)를 생성합니다.
    • 대안 (factorial(n - 1) * n): analyze -> analyze_application이 호출됩니다.
      • analyze_application* 연산자와 두 인수 factorial(n - 1)n을 분석합니다.
      • 중요: factorial(n - 1)을 분석할 때, factorial이라는 이름이 가리키는 것은 아직 분석 중인 함수 자신입니다. 하지만 이 단계에서는 factorial이 어떤 함수인지 "알아야" 하는 것이 아니라, 그저 factorial이라는 이름을 실행 시점에 환경에서 찾아서 호출하는 방법을 가진 실행 함수를 만들 뿐입니다.
      • 따라서 analyze_name이 호출되어 "환경에서 factorial이라는 이름의 값을 찾아라"라는 실행 함수를 생성합니다. 이 함수는 실행 단계가 되어야 비로소 factorial이 무엇인지 알게 됩니다.
  • 결과: analyze_conditionalpfun, cfun, afun을 자신의 클로저에 저장한 실행 함수를 반환합니다. 이 함수는 "조건을 평가하고, 참이면 결과를, 거짓이면 대안을 실행하라"는 방법을 알고 있습니다.

  • 함수 생성: 이 실행 함수는 make_function에 의해 factorial이라는 이름의 compound function으로 묶여 환경에 저장됩니다.

이 시점에서의 마법: factorial 함수 본문 안에서 factorial을 참조하는 부분은 실행 시점으로 미뤄진 채로(evaluated lazily) 분석됩니다. 분석기는 factorial이 뭔지 몰라도, "나중에 환경에서 찾아라"라는 실행 함수를 만들어낼 수 있습니다.

2. 실행 단계 (Execution Phase - 여러 번 발생)

이제 factorial(3)을 호출합니다.

  1. 함수 실행: evaluatefactorial(3)을 만나 analyze_application을 통해 실행 함수들을 가져옵니다.
  2. 환경 확장: n 매개변수에 3이 바인딩된 새로운 환경이 생성됩니다.
  3. 본문 실행: factorial 함수의 본문을 분석해 만들어뒀던 그 실행 함수가 호출됩니다. 환경은 n=3인 환경입니다.
    • 실행 함수는 pfun(조건 n === 1을 실행하는 함수)을 현재 환경으로 실행합니다. 3 === 1false입니다.
    • 따라서 afun(대안 factorial(n - 1) * n을 실행하는 함수)을 실행합니다.
  4. 대안 실행: afun은 내부에서 다시 analyze_application이 만들어낸 실행 함수들입니다.
    • * 연산자를 실행하는 함수를 호출합니다.
    • 두 번째 인수 n을 실행하는 함수를 호출해 3을 얻습니다.
    • 첫 번째 인수 factorial(n - 1)을 실행하는 함수를 호출합니다:
      • 이 함수는 analyze_name이 만들어낸 함수입니다. 현재 환경에서 factorial이라는 이름을 찾습니다.
      • 중요: 현재 환경의 외부 환경(outer environment)을 타고 올라가면, factorial 함수의 정의가 저장된 환경이 있습니다. 따라서 factorial이라는 이름의 값(즉, 방금 분석해 만들어둔 그 compound function)을 찾아냅니다.
      • n - 1을 계산해 2를 얻습니다.
      • 다시 execute_application을 호출합니다. 이제 factorial(2)를 호출하는 것과 완전히 동일한 과정이 시작됩니다.

이 과정이 재귀적으로 반복되다가, factorial(1)이 호출될 때 비로소 조건문의 pfuntrue를 반환하고 cfun1을 반환하며 재귀 호출 체인이 풀리기 시작합니다.

요약: 어떻게 한 번의 분석으로 충분한가?

  • 분석기의 역할은 코드의 구조를 해체하고, "실행 방법 지침서(실행 함수)"를 만드는 것입니다.
  • 이 지침서는 실행 시점에 필요한 정보(예: factorial이란 이름의 값)를 어떻게 찾을지에 대한 방법만을 포함할 뿐, 그 시점의 실제 값을 포함하지는 않습니다.
  • 따라서, 함수 본문이 자신을 재귀적으로 호출하더라도, 분석기는 그 호출의 구문 구조(예: 함수 이름은 factorial, 인수는 n - 1)만 분석하면 됩니다. 분석기는 factorial이 무엇인지 "알 필요가 없습니다". 그저 "실행 시점에 환경에서 factorial을 찾아라"라는 지침을 만들기만 하면 됩니다.
  • 이렇게 만들어진 지침서(실행 함수)는 실행 시점에 비로소 환경과 함께 완전한 의미를 갖게 되어 재귀 호출을 완벽히 수행할 수 있습니다.

이것이 바로 분석과 실행을 분리하는 방식의 힘입니다. 분석기는 코드의 정적 구조(static structure)만을 보고 실행 계획을 수립하고, 실행기는 그 계획을 따라 동적인 환경(dynamic environment)에서 값을 계산합니다.

AMB 평가기 구현 이해하기: 비결정적 자바스크립트를 위한 역추적 메커니즘

🎯 AMB 평가기의 핵심 개념

AMB 평가기는 비결정적 자바스크립트(nondeterministic JavaScript) 프로그램을 실행하기 위한 인터프리터입니다. 일반 자바스크립트와 달리, AMB 평가기는 프로그램이 여러 가능한 경로 중 하나를 선택할 수 있도록 하고, 막다른 골목(dead end) 에 도달하면 역추적(backtracking) 을 통해 이전 선택 지점으로 돌아가 다른 경로를 시도합니다.

🔄 연속(Continuations): 성공과 실패의 흐름 관리

AMB 평가기의 가장 중요한 개념은 연속(continuations) 입니다. 각 실행 함수는 세 가지 인자를 받습니다:

  1. 환경(environment): 변수와 값의 매핑
  2. 성공 연속(success continuation): 계산이 성공했을 때 호출할 함수
  3. 실패 연속(failure continuation): 계산이 실패했을 때 호출할 함수

성공 연속 (succeed)

  • (value, next_fail) => ... 형태의 함수
  • 값과 함께 다음 실패 시 사용할 실패 연속을 받음
  • 현재 값을 사용하여 계산을 계속 진행

실패 연속 (fail)

  • () => ... 형태의 함수
  • 다음에 시도할 대안을 저장하는 역할
  • 현재 경로가 실패하면 저장된 대안을 시도

💡 핵심 이해: 실패 연속은 "실패한 것"을 저장하는 것이 아니라 "다음에 시도할 대안"을 저장하는 메커니즘입니다!

🏗️ 평가기 구조와 작동 방식

ambeval: 최상위 평가 함수

function ambeval(component, env, succeed, fail) {
    return analyze(component)(env, succeed, fail);
}
  • 주어진 구성 요소(component)를 분석하고 실행 함수를 적용
  • 환경과 두 연속 함수를 실행 함수에 전달

analyze: 구성 요소 분석

function analyze(component) {
    return is_literal(component) ? analyze_literal(component) :
           is_name(component) ? analyze_name(component) :
           is_conditional(component) ? analyze_conditional(component) :
           is_amb(component) ? analyze_amb(component) :
           // ... 다른 구문 형태들 ...
}
  • 구성 요소의 유형을 식별하여 해당 분석 함수로 디스패치
  • 각 분석 함수는 실행 함수를 생성하여 반환

🧩 다양한 구문의 처리 방식

단순 표현식 (리터럴, 변수, 람다)

function analyze_literal(component) {
    return (env, succeed, fail) => succeed(literal_value(component), fail);
}
  • 즉시 성공 연속을 호출하여 값을 전달
  • 실패 연속은 변경 없이 전달

조건문 (Conditionals)

function analyze_conditional(component) {
    // 조건부, 결과, 대안 분석
    return (env, succeed, fail) =>
             pfun(env,
                  (pred_value, fail2) => // 조건 평가 성공 시
                    is_truthy(pred_value) 
                    ? cfun(env, succeed, fail2) // 조건이 참이면 결과 실행
                    : afun(env, succeed, fail2), // 조건이 거짓이면 대안 실행
                  fail); // 조건 평가 실패 시
}
  • 조건 평가 후 결과 또는 대안 실행
  • 새로운 실패 연속(fail2) 생성하여 전달

할당 (Assignments)

function analyze_assignment(component) {
    return (env, succeed, fail) =>
             vfun(env,
                  (val, fail2) => {
                      const old_value = lookup_symbol_value(symbol, env);
                      assign_symbol_value(symbol, val, env); // 새 값 할당
                      return succeed(val,
                                     () => { // 새로운 실패 연속: 할당 취소
                                         assign_symbol_value(symbol, old_value, env);
                                         return fail2(); // 원래 실패 연속 호출
                                     });
                  },
                  fail);
}
  • 역추적 시 할당 취소가 핵심
  • 성공 시 원래 값을 저장하고, 취소 로직이 포함된 실패 연속 생성

amb 표현식: 비결정적 선택

function analyze_amb(component) {
    const cfuns = map(analyze, amb_choices(component));
    return (env, succeed, fail) => {
               function try_next(choices) {
                   return is_null(choices)
                          ? fail() // 모든 선택지 소진 시 실패
                          : head(choices)(env, succeed, 
                                          () => try_next(tail(choices))); // 다음 선택지
               }
               return try_next(cfuns);
           };
}
  • 모든 선택지(alternatives)를 순차적으로 시도
  • 각 선택지의 실패 연속은 다음 선택지를 시도하도록 설정
  • 모든 선택지가 실패하면 최종적으로 원래 실패 연속 호출

🎮 드라이버 루프와 사용자 상호작용

function driver_loop(env) {
    function internal_loop(retry) {
        const input = user_read(input_prompt);
        if (input === "retry") {
            return retry(); // 저장된 실패 연속 실행
        } else {
            // 새 프로그램 평가
            return ambeval(program, program_env,
                (val, next_alternative) => { // 성공 연속
                    user_print(output_prompt, val);
                    return internal_loop(next_alternative); // 다음 대안 저장
                },
                () => { // 실패 연속
                    display("There are no more values of");
                    return driver_loop(program_env);
                });
        }
    }
    // ...
}
  • retry 기능: 사용자가 "retry" 입력 시 저장된 실패 연속 실행
  • 성공 시 다음 대안 저장: 값 출력 후 다음 대안을 시도할 수 있도록 실패 연속 저장

🔁 역추적 과정 예시: amb(1, 2, 3)

  1. 첫 번째 선택지 시도: 1 평가 → 성공 → 값 출력
  2. retry 입력: 저장된 실패 연속 실행 → () => try_next([2, 3])
  3. 두 번째 선택지 시도: 2 평가 → 성공 → 값 출력
  4. 다시 retry: 새로운 실패 연속 실행 → () => try_next([3])
  5. 세 번째 선택지 시도: 3 평가 → 성공 → 값 출력
  6. 다시 retry: () => try_next(null)fail() → "더 이상 값 없음" 출력

💡 핵심 통찰력

  1. 연속 전달 스타일(CPS): 모든 함수가 명시적인 연속을 받아들이고 전달하여 복잡한 제어 흐름 구현

  2. 실패 연속은 다음 대안: 실패 연속은 "실패" 자체가 아니라 "다음에 시도할 대안"을 저장

  3. 동적 연속 생성: 실행 시점에 연속 함수들이 동적으로 생성되고 중첩됨

  4. 역추적 자동화: require 실패나 사용자 retry 요청 시 저장된 실패 연속이 자동으로 실행되어 역추적

  5. 부수 효과 관리: 할당 연산은 역추적 시 자동으로 취소되도록 실패 연속을 설계

이 메커니즘을 통해 AMB 평가기는 복잡한 제약 조건 문제를 해결하는 데 효과적으로 사용될 수 있습니다. 사용자는 retry를 입력하기만 하면 평가기가 자동으로 다음 가능한 해를 찾아서 제시합니다.

다음은 한자가 포함된 부분을 한국어로 대치한 내용입니다:

쿼리 시스템 전체 실행 흐름 (Flow Analysis)

쿼리 시스템의 실행은 크게 1) 구문 분석 및 변환, 2) 평가 및 해 탐색, 3) 결과 인스턴스화 및 출력의 세 단계로 나눌 수 있습니다. 아래 다이어그램은 사용자가 job($x, list("computer", "wizard"))를 입력했을 때의 전반적인 데이터 흐름을 보여줍니다.

flowchart TD
    A[User Input<br/>job query with variable] --> B[Parse<br/>JavaScript AST 생성]
    B --> C[Convert to Query Syntax<br/>쿼리 최적화 표현으로 변환]
    C --> D[Find Assertions & Apply Rules<br/>패턴 매칭과 규칙 적용]
    D --> E[Evaluate Query<br/>스트림으로 프레임 생성]
    E --> F{For each result frame}
    F -->|Frame found| G[Instantiate Expression<br/>변수를 값으로 치환]
    G --> H[Unparse<br/>JavaScript 코드 문자열로 변환]
    H --> I[Output<br/>Complete job expression]
    F -->|More frames exist| F
Loading

코드 분석: 핵심 함수별 역할과 주석

1. 드라이버 루프 (query_driver_loop)

시스템의 시작점으로, 사용자와의 상호작용을 처리합니다.

function query_driver_loop() {
    // 1. 사용자로부터 입력을 받음 (예: "job($x, list("computer", "wizard"));")
    const input = user_read(input_prompt) + ";"; // Parse를 위해 세미콜론 추가
    if (is_null(input)) {
        display("evaluator terminated");
    } else {
        // 2. 입력 문자열을 JavaScript 추상 구문 트리(AST)로 파싱
        const expression = parse(input);
        // 3. AST를 쿼리 처리에 최적화된 내부 표현 방식으로 변환 (예: ['job', ['$x'], ['computer', 'wizard']])
        const query = convert_to_query_syntax(expression);

        // 4. 변환된 표현이 주장(단언) 추가인지 쿼리 실행인지 판단
        if (is_assertion(query)) {
            add_rule_or_assertion(assertion_body(query));
            display("Assertion added to data base.");
        } else {
            display(output_prompt);
            // 5. 쿼리 평가 실행: evaluate_query는 프레임 스트림을 반환함
            const result_frames = evaluate_query(query, singleton_stream(null));
            // 6. 결과 프레임 스트림을 결과 문자열 스트림으로 변환
            const result_strings = stream_map(
                frame => unparse(instantiate_expression(expression, frame)),
                result_frames
            );
            // 7. 결과 문자열 스트림을 사용자에게 출력
            display_stream(result_strings);
        }
        // 8. 다음 루프를 위해 자기 자신을 재귀 호출
        return query_driver_loop();
    }
}

2. 평가기 (evaluate_query)

쿼리의 종류(단순, and, or, not 등)를 식별하고 해당 처리기로 전달하는 중앙 관문(Central Gateway) 역할을 합니다.

function evaluate_query(query, frame_stream) {
    // query의 head (예: "and", "or")를 보고 해당 처리 함수를 가져옴
    const qfun = get(type(query), "evaluate_query");
    // 처리 함수가 등록되지 않은 경우 (예: ['job', ...]) 단순 쿼리로 간주
    return is_undefined(qfun)
           ? simple_query(query, frame_stream) // 단순 쿼리 처리
           : qfun(contents(query), frame_stream); // 복합 쿼리 처리 (and, or 등)
}
// 복합 쿼리 처리기 등록 예시
put("and", "evaluate_query", conjoin);
put("or", "evaluate_query", disjoin);
put("not", "evaluate_query", negate);

3. 단순 쿼리 처리기 (simple_query)

하나의 패턴에 대한 모든 가능한 해를 찾는 가장 기본적인 연산입니다.

function simple_query(query_pattern, frame_stream) {
    return stream_flatmap(
        frame => {
            // A. 데이터베이스의 주장(단언)과 매칭하여 프레임 확장
            const assertions_stream = find_assertions(query_pattern, frame);
            // B. 규칙을 적용하여 프레임 확장 (지연 평가됨)
            const rules_stream = () => apply_rules(query_pattern, frame);
            // C. 두 스트림을 결합하여 하나의 스트림으로 반환
            return stream_append_delayed(assertions_stream, rules_stream);
        },
        frame_stream
    );
}

4. 패턴 매칭 (pattern_match) & 통일화 (unify_match)

pattern_match (단방향)

function pattern_match(pattern, data, frame) {
    if (frame === "failed") return "failed";
    if (equal(pattern, data)) return frame; // 패턴과 데이터가 같으면 성공
    if (is_variable(pattern))
        return extend_if_consistent(pattern, data, frame); // 변수를 데이터에 바인딩
    if (is_pair(pattern) && is_pair(data)) {
        // 재귀적으로 머리와 꼬리를 매칭
        return pattern_match(
            tail(pattern),
            tail(data),
            pattern_match(head(pattern), head(data), frame)
        );
    }
    return "failed"; // 모든 경우에 해당하지 않으면 실패
}

unify_match (양방향, 규칙 적용 시 사용)

function unify_match(p1, p2, frame) {
    if (frame === "failed") return "failed";
    if (equal(p1, p2)) return frame;
    if (is_variable(p1)) return extend_if_possible(p1, p2, frame); // p1이 변수
    if (is_variable(p2)) return extend_if_possible(p2, p1, frame); // *** p2도 변수일 수 있음 (통일화의 특징)
    if (is_pair(p1) && is_pair(p2)) {
        return unify_match(
            tail(p1), tail(p2),
            unify_match(head(p1), head(p2), frame)
        );
    }
    return "failed";
}

핵심 차이점: pattern_matchpattern에만 변수가 올 수 있지만, unify_matchp1p2 모두에 변수가 올 수 있습니다. 이로 인해 extend_if_possible에는 상호 참조를 검사하는 depends_on 로직이 추가됩니다.

5. 규칙 적용 (apply_a_rule)

function apply_a_rule(rule, query_pattern, query_frame) {
    // *** 핵심: 규칙 내 모든 변수의 이름을 고유하게 변경 ***
    const clean_rule = rename_variables_in(rule);
    // 1. 쿼리 패턴과 규칙의 결론(결과)을 통일화
    const unify_result = unify_match(query_pattern, conclusion(clean_rule), query_frame);
    if (unify_result === "failed") {
        return null; // 통일화 실패
    } else {
        // 2. 통일화로 생성된 새로운 프레임에서 규칙의 본문(조건)을 평가
        return evaluate_query(rule_body(clean_rule), singleton_stream(unify_result));
    }
}

Why Renaming?: 서로 다른 규칙 적용에서 동일한 변수 이름(예: $x)이 사용되는 경우, 서로 충돌하는 것을 방지하기 위해 이름을 $x_1, $x_2와 같이 고유하게 만들어줍니다.

6. 결과 생성 (instantiate_expression)

평가 결과로 나온 프레임(변수 바인딩 집합) 을 원본 쿼리 문자열에 끼워넣어 사용자가 이해할 수 있는 답변을 생성합니다.

function instantiate_expression(expression, frame) {
    if (is_variable(expression)) {
        // 표현식이 변수면, 프레임에서 그 값을 찾아 반환
        const value = instantiate_term(expression, frame); // 값을 추출
        return convert(value); // 값을 JavaScript AST 형태로 변환
    } else if (is_pair(expression)) {
        // 표현식이 리스트면, 머리와 꼬리를 재귀적으로 인스턴스화
        return pair(instantiate_expression(head(expression), frame),
                   instantiate_expression(tail(expression), frame));
    } else {
        // 표현식이 리터럴이면, 그대로 반환
        return expression;
    }
}
// 예: expression이 `job($x, ...)`이고 frame이 `{ $x: ['Bitdiddle', 'Ben'] }`이면,
// 결과는 `job(['Bitdiddle', 'Ben'], ...)`의 AST 형태가 됨.

결론: 시스템의 핵심 아이디어

  1. 스트림과 지연 평가: 모든 가능한 해를 한꺼번에 생성하지 않고 필요할 때마다 생성합니다. 이는 무한한 해가 존재할 수 있는 상황을 효율적으로 처리할 수 있게 해주는 핵심 메커니즘입니다. interleave_delayedOR 연산 시 한쪽이 무한 스트림이어도 다른쪽 결과를 보여줄 수 있게 합니다.
  2. 프레임: 변수와 값을 연결하는 바인딩의 집합입니다. 평가 과정은 초기 빈 프레임에서 시작하여 패턴 매칭과 통일화를 통해 프레임을 점점 확장해 나가는 과정입니다.
  3. 규칙과 통일화: 규칙 적용은 통일화(Unification)라는 더 강력한 패턴 매칭을 사용하며, 변수 이름 변경을 통해 각 규칙 적용이 독립적이도록 만듭니다.
  4. 구문 변환: 사용자 입력 문자열 → JavaScript AST → 내부 쿼리 표현 → 평가 → JavaScript AST → 결과 문자열. 이와 같은 변환 과정을 통해 처리 단계를 명확히 분리하고 효율성을 높입니다.

이 시스템은 논리 프로그래밍의 핵심인 선언적 지식(What)절차적 실행(How) 의 분리를 매우 우아하게 구현한 사례입니다. 사용자는 원하는 결과를 질의로 표현하기만 하면, 시스템이 효율적인 탐색 전략을 통해 그 결과를 찾아줍니다.

5.2 A Register-Machine Simulator (레지스터 머신 시뮬레이터)

레지스터 머신 설계를 이해하기 위해, 설계된 머신이 예상대로 동작하는지 테스트해야 한다.
수동 시뮬레이션은 매우 지루하므로, 레지스터 머신 언어로 설명된 머신을 시뮬레이션하는 프로그램을 구축한다.

이 시뮬레이터는 네 가지 인터페이스 함수를 제공하는 JavaScript 프로그램이다.

  • make_machine: 주어진 레지스터, 연산, 컨트롤러로 머신 모델을 구축하고 반환한다.
  • set_register_contents: 시뮬레이션된 머신의 레지스터에 값을 저장한다.
  • get_register_contents: 시뮬레이션된 머신의 레지스터 내용을 반환한다.
  • start: 컨트롤러 시퀀스의 시작부터 끝까지 머신 실행을 시뮬레이션한다.

예를 들어, GCD 머신을 정의하고 사용하는 방법은 다음과 같다.


const gcd_machine = make_machine( ... ); // 머신 정의
set_register_contents(gcd_machine, "a", 206); // 입력 설정
set_register_contents(gcd_machine, "b", 40);
start(gcd_machine); // 실행
get_register_contents(gcd_machine, "a"); // 결과 확인

이 시뮬레이터는 저수준 명령을 시뮬레이션하므로 일반 JavaScript 함수보다 느리게 동작한다.


5.2.1 The Machine Model (머신 모델)

make_machine은 메시지 전달 기법을 사용하여 지역 상태를 가진 함수로 머신 모델을 생성한다.
먼저 make_new_machine을 호출하여 모든 레지스터 머신에 공통적인 기본 모델을 구축한다.

이 기본 모델은 레지스터, 스택, 그리고 명령을 하나씩 처리하는 실행 메커니즘을 포함한다.

make_machine은 이 기본 모델에 특정 머신의 레지스터, 연산, 컨트롤러를 추가로 확장한다.
먼저 제공된 레지스터 이름마다 레지스터를 할당하고 지정된 연산을 설치한다.
그런 다음 어셈블러를 사용해 컨트롤러 목록을 머신 명령으로 변환하고 명령 시퀀스로 설치한다.

레지스터와 스택은 지역 상태를 가진 함수로 표현된다.
레지스터는 값을 저장하거나 접근할 수 있고, 스택은 push, pop, 초기화 요청을 처리한다.

기본 머신 모델은 스택, 초기 빈 명령 시퀀스, 스택 초기화 연산을 포함하는 연산 목록, 그리고 flag와 pc(program counter) 레지스터를 포함하는 레지스터 테이블로 구성된다.

allocate_register는 레지스터 테이블에 새 항목을 추가하고, lookup_register는 레지스터를 조회한다.

flag 레지스터는 분기 제어에, pc 레지스터는 명령 실행 순서 결정에 사용된다.
execute 함수는 pc가 가리키는 명령을 실행하고, 명령 실행 함수가 pc를 수정하여 다음 명령을 가리키게 한다.

시뮬레이터는 start, get_register_contents, set_register_contents 함수를 통해 머신 조작을 위한 대체 인터페이스를 제공한다.


5.2.2 The Assembler (어셈블러)

어셈블러는 머신의 컨트롤러 명령 시퀀스를 실행 함수가 연결된 해당 머신 명령 목록으로 변환한다.
이는 분석과 실행 시간을 분리하여 평가기를 속도 향상시킨 4.1.7절의 접근법과 유사하다.

어셈블러는 먼저 컨트롤러 시퀀스를 스캔하여 레이블과 명령을 분리한다.
명령 목록과 레이블이 명령 목록의 위치를 가리키는 테이블을 구축한다.
그런 다음 update_insts를 사용해 각 명령에 실행 함수를 생성하고 삽입한다.

assemble 함수는 컨트롤러와 머신 모델을 인수로 받아 모델에 저장할 명령 시퀀스를 반환한다.
extract_labels는 컨트롤러에서 명령 데이터 구조와 레이블 테이블을 구축하는 도우미 함수이다.

명령 데이터 구조는 컨트롤러 명령과 실행 함수를 쌍으로 묶는다.
실행 함수는 extract_labels로 명령이 구축될 때는 아직 사용할 수 없으며, update_insts에 의해 나중에 삽입된다.

레이블 테이블의 항목은 레이블 이름과 명령 목록의 위치를 쌍으로 묶는다.
lookup_label 함수는 레이블 이름으로 테이블에서 항목을 조회한다.

동일한 레이블 이름이 두 위치를 나타내는 경우 오류를 신호하도록 extract_labels를 수정할 수 있다.


5.2.3 Instructions and Their Execution Functions (명령과 그 실행 함수)

어셈블러는 make_execution_function을 호출해 컨트롤러 명령에 대한 실행 함수를 생성한다.
이 함수는 명령 유형에 따라 적절한 실행 함수 생성으로 분기한다.

각 명령 유형(assign, test, branch, go_to, save, restore, perform)에 대해 실행 함수 생성기가 있다.
이러한 함수들은 명령의 의미를 결정한다.

  • assign: 레지스터에 값을 할당하고 pc를 전진시킨다.
  • test: 조건을 검사하고 결과를 flag에 저장한 후 pc를 전진시킨다.
  • branch: flag 내용을 확인해 분기 여부를 결정한다.
  • go_to: 레이블이나 레지스터로 pc를 설정한다.
  • save 및 restore: 스택에 레지스터를 저장하거나 복원하며 pc를 전진시킨다.
  • perform: 작업을 수행하고 pc를 전진시킨다.

실행 함수 생성기는 명령 인수를 분석하고, 시뮬레이션 시간에 실제 값을 생성하는 함수를 구축한다.
이 작업은 어셈블 시간에 한 번 수행되므로 효율적이다.

하위 표현식(reg, label, constant) 및 연산 표현식(op)에 대한 실행 함수 생성기도 있다.
연산 표현식의 경우, 피연산자에 대한 실행 함수를 생성하고 시뮬레이션 시간에 기본 JavaScript 함수를 적용한다.


5.2.4 Monitoring Machine Performance (머신 성능 모니터링)

시뮬레이터는 머신 설계의 정확성을 검증할 뿐만 아니라 성능 측정에도 유용하다.
예를 들어, 스택 작업 횟수를 측정하는 "미터"를 설치할 수 있다.

make_stack 함수를 수정해 푸시 횟수와 최대 스택 깊이를 추적하게 하고, 통계를 출력하는 메시지를 인터페이스에 추가한다.
그런 다음 기본 머신 모델에 스택 통계를 출력하는 연산을 추가한다.

이를 통해 계산에 사용된 스택 작업의 총 횟수와 최대 깊이를 확인할 수 있다.
예를 들어, 팩토리얼 계산과 같은 작업의 성능 프로필을 분석하는 데 사용된다.

연습문제에서는 명령 카운팅, 명령 트레이싱, 레지스터 트레이싱, 브레이크포인트 기능 등 추가 모니터링 및 디버깅 기능을 추가하는 방법을 설명한다.

5.3 Storage Allocation and Garbage Collection

저장소 할당과 쓰레기 수거

JavaScript 평가기를 레지스터 머신으로 구현할 때, 리스트 구조를 조작하는 기본 연산이 primitive인 list-structured memory가 있다고 가정한다.
이는 인터프리터의 제어 메커니즘에 초점을 맞출 때 유용한 추상화이지만, 실제 컴퓨터의 primitive 데이터 연산을 반영하지는 않는다.

좀 풀어서 설명:

  • 이 책(SICP 계열)에서 말하는 리스트는 포인터 기반의 cons cell 리스트.
  • 기본 연산이 primitive: 리스트 조작 연산을 레지스터 머신의 기본 내장 연산(primitive) 으로 가정한다.
  • 이러한 리스트 기반 연산을 제공하는 메모리를 바탕으로 JavaScript 평가기를 구현한다고 전제하는 것이다.
  • 그러나 이 구현은 실제 컴퓨터의 primitive 데이터 연산을 반영하지는 않는다.

리스트 구조를 효율적으로 표현하기 위해 두 가지 고려사항이 있다:

  • pairs의 "box-and-pointer" 구조를 표현하는 방법과 계산 진행에 따른 메모리 관리 방법.
  • JavaScript 시스템은 새로운 데이터 객체를 지속적으로 생성해야 하므로, automatic storage allocation facility(자동 저장소 할당 기능)가 필요하다.
  • 더 이상 필요하지 않은 데이터 객체의 메모리는 자동으로 재활용되며, 이를 garbage collection이라고 한다.

5.3.1 Memory as Vectors

벡터 구조로 표현된 메모리

일반적인 컴퓨터 메모리는 각각 정보를 담을 수 있는 cubbyhole(직역: 작은 보관소/방)의 배열로 생각할 수 있다.
각 cubbyhole은 address 또는 location이라는 고유한 이름을 가진다.

메모리 주소는 데이터로 취급되어 메모리 위치에 저장되고 머신 레지스터에서 조작될 수 있다.
리스트 구조 표현은 such address arithmetic의 응용이다.

메모리를 모델링하기 위해 vector라는 새로운 데이터 구조를 사용한다.
벡터는 정수 인덱스를 통해 개별 요소에 접근할 수 있는 복합 데이터 객체이다.

벡터 조작을 위해 두 함수를 사용한다:

  • vector_ref(vector, n): 벡터의 n번째 요소를 반환한다.
  • vector_set(vector, n, value): 벡터의 n번째 요소를 지정된 값으로 설정한다.

데이터 표현:
컴퓨터 메모리를 두 벡터(the_heads와 the_tails)로 나눈다.
pairs에 대한 포인터는 두 벡터의 인덱스이다.

pairs의 head는 지정된 인덱스의 the_heads 항목이고, tail은 지정된 인덱스의 the_tails 항목이다.
pairs 이외의 객체(숫자, 문자열 등)를 표현하고 데이터 타입을 구별하기 위해 typed pointers를 사용한다.
두 데이터 객체는 포인터가 동일하면 동일한(===) 것으로 간주한다.

기본 리스트 연산 구현:
각 "primitive" 리스트 연산을 하나 이상의 primitive 벡터 연산으로 대체한다.
the_heads와 the_tails 레지스터를 사용하여 메모리 벡터를 식별하고, vector_ref와 vector_set을 primitive 연산으로 가정한다.
pair 연산은 사용되지 않은 인덱스를 할당하고 인수들을 해당 인덱스 벡터 위치에 저장하여 수행된다.
free 레지스터는 사용 가능한 다음 인덱스를 포함하는 pair 포인터를 보유한다.

스택 구현: 스택은 리스트로 모델링될 수 있다.
스택은 특수 레지스터 the_stack이 가리키는 저장된 값의 리스트이다.
save(reg)는 assign("the_stack", list(op("pair"), reg(reg), reg("the_stack")))으로 구현된다.
restore(reg)는 assign(reg, list(op("head"), reg("the_stack")))과 assign("the_stack", list(op("tail"), reg("the_stack")))으로 구현된다.

initialize_stack는 assign("the_stack", constant(null))로 구현된다.

5.3.2 Maintaining the Illusion of Infinite Memory

무한 메모리라는 환상의 유지

리스트 구조 구현 방법은 무한한 메모리가 있는 경우 문제를 해결하지만, 실제 컴퓨터에서는 새로운 pairs를 구성하기 위한 free space가 결국 부족해진다.

일반적인 계산에서 생성된 대부분의 pairs는 중간 결과를 보관하는 데만 사용되며, 이러한 결과가 접근된 후에는 더 이상 필요하지 않아 garbage가 된다.

garbage collection은 할당된 pairs 중 계산의 미래에 영향을 줄 수 없는 객체들을 결정하는 방법이다.
stop-and-copy garbage collection 방법은 메모리를 두 반으로 나누어 작업 메모리와 free 메모리로 구성한다.
작업 메모리가 가득 차면 garbage collection을 수행하여 유용한 pairs를 free 메모리로 복사한다.
유용한 pairs는 머신 레지스터에서 시작하는 head와 tail 포인터를 추적하여 찾는다.

stop-and-copy garbage collector 구현: root 레지스터는 접근 가능한 모든 데이터를 eventually 가리키는 구조에 대한 포인터를 포함한다. garbage collection은 현재 작업 메모리의 free cells가 exhausted될 때 trigger된다.
garbage-collection process가 완료되면 root 포인터는 new memory를 가리키고, root에서 접근 가능한 모든 객체들이 new memory로 이동되며, free 포인터는 new pair를 할당할 수 있는 new memory의 다음 위치를 나타낸다.
garbage-collection process의 상태는 free와 scan 두 포인터로 제어된다.
알고리즘은 root가 가리키는 pair를 new memory의 시작 부분으로 relocate하는 것으로 시작한다.
객체를 relocate하기 위해 relocate_old_result_in_new 서브루틴을 사용한다.
객체가 이미 이동된 경우 broken-heart tag와 forwarding address를 사용하여 새 위치를 찾는다.
garbage collection process가 끝나면 old와 new 메모리의 역할을 바꾼다.

5.5 컴파일레이션: 개요 및 핵심 개념

  • 인터프리테이션 vs. 컴파일레이션: 이 장은 레지스터 머신에서 고급 언어를 실행하는 두 가지 전략을 대조합니다.

    • 인터프리테이션: 프로그램(인터프리터)이 머신에서 실행되어, 소스 프로그램의 각 구성 요소에 대해 해당하는 레지스터 연산을 수행합니다. 소스 프로그램은 데이터로 취급됩니다. 이는 유연하고 디버깅에 좋지만 반복적인 분석으로 인해 런타임 오버헤드가 발생합니다.
    • 컴파일레이션: 프로그램(컴파일러)이 소스 프로그램을 머신의 네이티브 언어로 작성된 동등한 프로그램(목적 프로그램)으로 번역합니다. 이 번역은 한 번만 일어납니다. 결과 코드는 반복적인 분석을 제거하고 최적화를 수행할 수 있어 훨씬 더 효율적일 수 있습니다.
  • 혼합 전략: 현대 시스템은 종종 둘을 결합합니다. 인터프리터와 컴파일된 함수가 서로를 호출할 수 있어, 프로그래머가 개발 중에는 코드를 인터프리트하고 프로덕션에서는 컴파일할 수 있습니다.

5.5.1 컴파일러의 구조

  • 최상위 디스패치: 컴파일러의 진입점은 compile 함수입니다. 컴포넌트의 구문 타입(리터럴, 이름, 적용, 조건문, 람다 등)에 대해 케이스 분석을 수행하고 특화된 코드 생성기로 디스패치합니다.
  • 타겟과 링키지: 코드 생성기는 두 가지 주요 인수를 받습니다:
    • 타겟: 컴포넌트의 계산된 값이 배치될 레지스터(예: val).
    • 링키지: 코드 실행 후 무엇을 해야 하는지를 설명합니다. 다음과 같을 수 있습니다:
      • "next": 다음 명령어로 진행.
      • "return": continue 레지스터의 주소로 점프(함수에서 리턴하기 위해).
      • 레이블: 특정 레이블로 점프.
  • 명령어 시퀀스: 코드 생성기의 출력은 명령어 시퀀스이며, 다음을 포함하는 구조로 표현됩니다:
    1. 필요한 레지스터 집합(초기화되어야 함).
    2. 수정된 레지스터 집합.
    3. 실제 명령어 목록.
  • 시퀀스 결합: 컴파일러는 preserving과 같은 스마트 결합기를 사용하여 명령어 시퀀스를 연결하고, 레지스터 내용을 보호하기 위해 필요한 경우에만 자동으로 saverestore 연산을 생성하여 스택 사용량을 최소화합니다.

5.5.2-5.5.3 특정 컴포넌트 컴파일 및 주요 최적화

텍스트는 다양한 구문 형태가 어떻게 컴파일되는지 자세히 설명하며, 중요한 최적화를 강조합니다:

  • 단순 컴포넌트: 리터럴과 이름은 직접적인 assign 명령어로 컴파일됩니다.
  • 조건문: 술어를 평가한 후 결과 또는 대안에 대한 코드로 분기하도록 컴파일됩니다.
  • 시퀀스: 순차적으로 컴파일됩니다. 주요 최적화는 return 문을 시퀀스의 끝으로 처리하여, 그 뒤의 "데드 코드" 컴파일을 피합니다.
  • 람다 표현식: 함수 객체 생성과 함수 본문 코드로 컴파일됩니다. 본문은 항상 return 문으로 끝나도록 강화됩니다.
  • 애플리케이션(함수 호출): 이것은 컴파일 프로세스의 핵심입니다. 코드는 다음과 같이 구성됩니다:
    1. 함수 표현식을 컴파일(fun에 결과).
    2. 인수 표현식들을 컴파일하고 인수 목록을 구성(argl에).
    3. fun의 함수를 argl의 인수에 적용하는 코드를 생성.
  • 꼬리 재귀: 컴파일러는 return 문에서 함수 애플리케이션에 대해 꼬리 재귀 코드를 생성합니다. 이는 중요한 최적화입니다. 호출에 대해 "return" 링키지를 사용함으로써, 호출된 함수의 리턴이 중간 리턴 단계 없이 원래 호출자의 리턴 주소로 직접 가므로 스택이 증가하지 않도록 보장합니다.
  • Return 문: 스택을 되돌리고(마커 사용) continue 레지스터를 복원한 후 그것으로 점프하도록 컴파일됩니다. 이는 함수 호출에 대해 생성된 스택 연산과 쌍을 이룹니다.

5.5.4 명령어 시퀀스 결합

이 섹션은 코드 조각을 병합하는 결합기(append_instruction_sequences, preserving, parallel_instruction_sequences)의 구현을 자세히 설명합니다. 알고리즘은 각 시퀀스와 연관된 레지스터 필요/수정 메타데이터를 사용하여 최소한의 스택 연산으로 최적 코드를 지능적으로 생성합니다.

5.5.5 예제 및 연습문제

  • 재귀 factorial 함수 컴파일의 상세한 연습이 제공되어 생성된 명령어 시퀀스를 보여줍니다.
  • 연습문제는 독자에게 스택 사용량 분석, 추가 최적화 구현(원시 함수의 오픈 코딩 등), 컴파일러 출력 이해에 도전하게 합니다.

5.5.6 어휘 주소 지정

  • 최적화: 주요 최적화는 느린 심볼릭 룩업(lookup_symbol_value, 런타임에 프레임을 검색)을 빠른 어휘 주소 지정으로 교체하는 것입니다.
  • 작동 방식: 컴파일러는 프로그램의 정적(어휘) 구조를 분석하여 각 이름의 어휘 주소를 결정합니다: 몇 개의 프레임 밖으로 나가야 하고 그 프레임 내의 어느 위치에 바인딩이 있는지.
  • 컴파일 타임 환경: 컴파일러는 이러한 주소를 계산하기 위해 환경의 어휘 중첩을 미러링하는 데이터 구조를 유지합니다. find_symbol과 같은 함수가 이름을 주소로 매핑하는 데 사용됩니다.
  • 새로운 연산: 머신은 직접 액세스를 위해 이러한 숫자 주소를 사용하는 lexical_address_lookuplexical_address_assign 연산으로 확장됩니다.

5.5.7 컴파일된 코드와 평가기 인터페이싱

  • 통합: 시스템은 명시적 제어 평가기(인터프리터)와 컴파일된 코드가 함께 작동할 수 있도록 수정됩니다.
  • compile_and_go: 프로그램을 컴파일하고, 객체 코드를 머신에 로드하고, 실행하는 함수가 구현됩니다. 이는 동일한 함수의 인터프리티드 실행과 컴파일된 실행 간의 스택 연산을 비교하여 효율성 향상을 보여줍니다(예: 컴파일된 factorial은 훨씬 적은 push를 사용).
  • 호출 규약: 평가기의 apply_dispatch가 컴파일된 함수를 인식하고 처리하도록 수정되어, 그들의 진입점으로 직접 점프합니다.
  • 각각의 이점:
    • 인터프리터: 디버깅과 개발에 더 좋음; 실행 단계가 고급 언어 추상화 관점에서 구성됩니다.
    • 컴파일러: 프로덕션에 더 좋음; 단계가 머신 연산 관점에서 구성되고 대폭 최적화될 수 있어 더 빠르게 실행됩니다.

요약하면, 이 장은 컴파일을 통해 고급 언어(JavaScript)와 머신 언어 사이의 격차를 메우는 방법을 설명하며, 컴파일러의 구조, 다양한 언어 구성 요소를 번역하는 전략, 그리고 컴파일된 코드를 효율적으로 만드는 꼬리 재귀와 어휘 주소 지정과 같은 중요한 최적화에 초점을 맞춥니다.


5.5 Compilation: Overview & Core Concepts

  • Interpretation vs. Compilation: The chapter contrasts two strategies for executing high-level languages on a register machine.

    • Interpretation: A program (the interpreter) runs on the machine and, for each source program component, performs the corresponding register operations. The source program is treated as data. This is flexible and good for debugging but incurs runtime overhead for repeated analysis.
    • Compilation: A program (the compiler) translates the source program into an equivalent program (the object program) written in the machine's native language. This translation happens once. The resulting code can be much more efficient as it eliminates repetitive analysis and can perform optimizations.
  • Mixed Strategy: Modern systems often combine both. Interpreted and compiled functions can call each other, allowing programmers to interpret code during development and compile it for production.

5.5.1 Structure of the Compiler

  • Top-Level Dispatch: The compiler's entry point is the compile function. It performs a case analysis on the syntactic type of the component (literal, name, application, conditional, lambda, etc.) and dispatches to specialized code generators.
  • Target and Linkage: Code generators take two key arguments:
    • Target: The register where the computed value of the component should be placed (e.g., val).
    • Linkage: Describes what should happen after the code executes. It can be:
      • "next": Proceed to the next instruction.
      • "return": Jump to the address in the continue register (for returning from a function).
      • A label: Jump to a specific label.
  • Instruction Sequences: The output of code generators is an instruction sequence, represented as a structure containing:
    1. The set of registers needed (must be initialized).
    2. The set of registers modified.
    3. The list of actual instructions.
  • Combining Sequences: The compiler uses smart combiners like preserving to append instruction sequences while automatically generating save and restore operations only when necessary to protect register contents, minimizing stack usage.

5.5.2-5.5.3 Compiling Specific Components & Key Optimizations

The text details how different syntactic forms are compiled, highlighting crucial optimizations:

  • Simple Components: Literals and names are compiled into direct assign instructions.
  • Conditionals: Compiled to evaluate the predicate and then branch to code for the consequent or alternative.
  • Sequences: Compiled sequentially. A key optimization treats a return statement as the end of a sequence, avoiding compilation of "dead code" that follows it.
  • Lambda Expressions: Compiled into a function object construction followed by the function body code. The body is augmented to ensure it always ends with a return statement.
  • Applications (Function Calls): This is the heart of the compilation process. The code is structured as:
    1. Compile the function expression (result in fun).
    2. Compile the argument expressions and construct the argument list (in argl).
    3. Generate code to apply the function in fun to the arguments in argl.
  • Tail Recursion: The compiler generates tail-recursive code for function applications in return statements. This is a critical optimization. By using the "return" linkage for the call, it ensures the stack does not grow because the called function's return directly goes to the original caller's return address, without needing an intermediate return step.
  • Return Statements: Compiled to revert the stack (using a marker) and restore the continue register before jumping to it. This pairs with the stack operations generated for function calls.

5.5.4 Combining Instruction Sequences

This section details the implementation of the combiners (append_instruction_sequences, preserving, parallel_instruction_sequences) that merge code fragments. The algorithms use the register need/modify metadata associated with each sequence to intelligently generate optimal code with minimal stack operations.

5.5.5 Example & Exercises

  • A detailed walkthrough of compiling a recursive factorial function is provided, showing the generated instruction sequence.
  • Exercises challenge the reader to analyze stack usage, implement further optimizations (like open-coding primitives), and understand the output of the compiler.

5.5.6 Lexical Addressing

  • Optimization: A major optimization is replacing slow symbolic lookups (lookup_symbol_value, which searches frames at runtime) with fast lexical addressing.
  • How it works: The compiler analyzes the static (lexical) structure of the program to determine the lexical address of each name: how many frames out and what position within that frame the binding is located.
  • Compile-Time Environment: The compiler maintains a data structure that mirrors the lexical nesting of environments to compute these addresses. Functions like find_symbol are used to map a name to its address.
  • New Operations: The machine is extended with lexical_address_lookup and lexical_address_assign operations that use these numeric addresses for direct access.

5.5.7 Interfacing Compiled Code to the Evaluator

  • Integration: The system is modified to allow the explicit-control evaluator (interpreter) and compiled code to work together.
  • compile_and_go: A function is implemented that compiles a program, loads the object code into the machine, and runs it. This demonstrates the efficiency gain by comparing stack operations between interpreted and compiled execution of the same function (e.g., compiled factorial uses far fewer pushes).
  • Calling Conventions: The evaluator's apply_dispatch is modified to recognize and handle compiled functions, jumping directly to their entry point.
  • Benefits of Each:
    • Interpreter: Better for debugging and development; execution steps are organized in terms of high-level language abstractions.
    • Compiler: Better for production; executes faster as steps are organized in terms of machine operations and can be heavily optimized.

In summary, this chapter explains how to bridge the gap between a high-level language (JavaScript) and machine language through compilation, focusing on the structure of a compiler, strategies for translating different language constructs, and crucial optimizations like tail recursion and lexical addressing that make compiled code efficient.

5.4 명시적 제어 평가기(Explicit-Control Evaluator) 요약

1. 개요 및 목적

  • 목표: 4장의 메타순환 평가기(JavaScript로 작성된 JavaScript 인터프리터)를 레지스터 머신의 관점에서 다시 구현하는 것.
  • 의의:
    • 함수 호출, 인자 전달 같은 평가 과정의 내부 메커니즘을 레지스터와 스택 연산으로 명시적으로 설명합니다.
    • 이 평가기는 기계어와 유사한 형태로 작성되어 레지스터 머신 시뮬레이터(5.2절)에서 실행되거나, JavaScript 평가기를 위한 하드웨어(특수 목적 칩) 의 설계 기초로 사용될 수 있습니다.

2. 레지스터와 연산

  • 주요 레지스터:
    • comp: 평가할 구성 요소(expression/statement)
    • env: 평가 환경
    • val: 평가 결과 값
    • continue: 재귀 평가 후 복귀할 위치 (복귀점)
    • fun: 적용할 함수
    • argl: 평가된 함수 인자들의 리스트
    • unev: 평가되지 않은 구성 요소들의 리스트 (임시 저장)
  • 연산: 설명의 명확성을 위해 4장의 추상 구문 함수(is_literal, make_function 등)와 환경 조작 함수를 기본 연산(primitive operations) 으로 가정합니다. 실제 저수준 구현에서는 이를 더 기본적인 리스트 연산으로 분해해야 합니다.

3. 평가기 구조: 디스패처와 기본 평가 (5.4.1)

  • 핵심: eval_dispatch

    • 평가기의 시작점이며 메타순환 평가기의 evaluate 함수에 해당합니다.
    • comp의 구문 타입을 검사(testbranch)하여 해당하는 평가 로직(예: ev_literal, ev_application)으로 분기하는 큰 switch문 같은 구조입니다.
  • 단순 표현식 평가:

    • 리터럴(ev_literal): 값을 바로 val에 담고 continue로 이동.
    • 이름(ev_name): 환경에서 이름의 값을 조회(lookup_symbol_value)하여 val에 담고 continue로 이동.
    • 람다 식(ev_lambda): 매개변수와 몸통을 추출해 함수 객체를 생성(make_function)하고 val에 담음.
  • 조건문(ev_conditional) 평가:

    1. 현재 comp(조건문), env, continue를 스택에 저장.
    2. 조건식(predicate)을 comp에 넣고 eval_dispatch로 이동해 평가.
    3. 평가 결과(val)를 검사(is_falsy)해 consequent 또는 alternativecomp에 넣고 다시 eval_dispatch로 이동.
  • 문장 시퀀스(ev_sequence) 평가:

    • unev에 평가할 문장 리스트를 저장.
    • 루프를 통해 각 문장을 순서대로 평가:
      • 마지막 문장이 아닐 경우: unev, env를 저장 → 문장 평가 → 저장된 값 복원 → 다음 문장으로 이동.
      • 마지막 문장일 경우: continue를 스택에서 미리 복원 → 문장 평가. 이렇게 하면 마지막 문장 평가 후 바로 저장된 복귀점(continue)으로 이동합니다.
    • Return 문과의 관계: 시퀀스 평가 도중 return 문이 실행되면 시퀀스 평가 루프를 벗어나 함수의 복귀점으로 바로 이동합니다. 따라서 시퀀스 평가 코드는 return 문의 존재를 알 필요가 없습니다.

4. 함수 적용 평가 (5.4.2)

  • 함수 적용 단계:

    1. 함수 식 평가: env와 인자식 리스트(unev)를 저장 → 함수 식을 comp에 넣고 eval_dispatch로 이동.
    2. 인자식 평가: 평가된 함수를 fun에 저장. argl을 빈 리스트로 초기화한 후 루프를 돌며 각 인자식을 평가하고 결과를 argl에 추가합니다. 마지막 인자는 최적화를 위해 별도로 처리됩니다.
    3. 함수 적용(apply_dispatch): fun이 기본 함수인지(primitive_apply) 혼합 함수인지(compound_apply)에 따라 분기합니다.
  • 혼합 함수 적용(compound_apply):

    1. 함수가 가진 환경을 함수 인자(argl)로 확장합니다.
    2. 함수의 몸통을 comp에 넣습니다.
    3. 스택 마커(push_marker_to_stack) 를 push하여 함수 호출 시작점을 표시합니다.
    4. continuereturn_undefined로 설정하고 eval_dispatch로 이동해 몸통을 평가합니다.
  • Return 문 처리(ev_return):

    • revert_stack_to_marker로 함수 실행 중 저장된 모든 스택 프레임(마커까지)을 제거합니다.
    • 원래의 continue(함수 호출자가 저장한 복귀점)를 복원합니다.
    • Return 식을 평가한 후 그 결과를 가지고 바로 복귀점으로 이동합니다. 이 구현이 꼬리 재귀(Tail Recursion) 를 가능하게 합니다.
  • 꼬리 재귀(Tail Recursion):

    • 의미: 함수의 마지막 연산이 자기 자신(또는 다른 함수)을 호출하는 경우, 호출 후 돌아올 위치를 저장할 필요가 없어 스택이 증가하지 않습니다.
    • 예시: sqrt_iter 같은 함수는 명시적 제어 평가기에서 상수 공간만으로 반복 실행할 수 있습니다.
    • 핵심: ev_return에서 Return 식을 평가하기 전에 스택을 정리하고 원래 continue를 복원하기 때문에, Return 식 평가 자체가 마치 함수를 호출한 바로 그 시점에서 평가되는 것과 같은 환경이 만들어집니다.

5. 블록, 할당, 선언 (5.4.3)

  • 블록(ev_block):
    • 블록 내 선언된 모든 변수를 스캔(scan_out_declarations)해 "*unassigned*" 값으로 초기화된 새 환경 프레임을 만든 후, 그 환경에서 블록 몸통을 평가합니다.
  • 할당(ev_assignment):
    • 할당 값 식을 평가한 후, 환경에 그 값을 지정(assign_symbol_value)합니다. 할당의 결과 값은 지정된 값本身입니다.
  • 선언(ev_declaration):
    • 함수 선언은 상수 선언으로 변환됩니다.
    • 선언 값 식을 평가한 후, 환경에 그 값을 지정합니다. 선언문 자체의 결과 값은 undefined입니다.

6. 평가기 실행 및 모니터링 (5.4.4)

  • 드라이버 루프(read_evaluate_print_loop):
    • 사용자로부터 프로그램을 읽고(user_read), 파싱하고(parse), 전역 환경을 확장하고, eval_dispatch로 평가를 보낸 후 결과(val)를 출력(user_print)하는 사이클을 반복합니다.
  • 성능 모니터링:
    • 스택 시뮬레이터의 통계 기능을 이용해 스택 push 횟수와 최대 깊이를 측정할 수 있습니다.
    • 이를 통해 꼬리 재귀 함수(상수 공간)와 비꼬리 재귀 함수(선형 공간)의 효율성을 비교·분석할 수 있습니다 (연습문제 5.27, 5.28).

7. 주요 개념 및 특징

  • 명시적 제어(Explicit Control): 평가 과정의 모든 단계(환경 확장, 인자 평가, 함수 적용, 복귀)가 컨트롤러의 go_to 지시와 스택 조작으로 명시적으로 기술됩니다.
  • 꼬리 재귀 최적화: Return 문 처리 방식을 통해 구현되어 반복 프로세스를 상수 공간으로 실행할 수 있습니다.
  • 추상화의 수준: 이 평가기는 실제 하드웨어 구현에 한 단계 다가선 모델이며, 여전히 일부 고수준 연산(구문 함수 등)을 사용한다는 점에서 추상적인 기계 설계라고 할 수 있습니다.
// const z = pair(1, 2);
// head(z);
// tail(z);
const make_point = pair;
const x_point = head;
const y_point = tail;
const make_segment = pair;
const start_segment = head;
const end_segment = tail;
function midpoint_segment(line) {
return make_segment(
(x_point(start_segment(line)) + x_point(end_segment(line)))/2,
(y_point(start_segment(line)) + y_point(end_segment(line)))/2
);
}
function print_point(p) {
return display("(" + stringify(x_point(p)) + ", "
+ stringify(y_point(p)) + ")");
}
// GPT 생성 테스트 코드
// 점 생성
const p1 = make_point(0, 0);
const p2 = make_point(4, 6);
// 선분 생성
const seg = make_segment(p1, p2);
// 중점 계산
const mid = midpoint_segment(seg);
// 출력
print_point(p1); // 예상: (0, 0)
print_point(p2); // 예상: (4, 6)
print_point(mid); // 예상: (2, 3)

SICP in JS 2.3.2-2.3.4 요약 및 학습 가이드

1. 2.3.2 기호 미분 (Symbolic Differentiation)

핵심 개념

  • 기호 미분: 대수적 표현식을 변수에 대해 미분하는 과정
  • 데이터 추상화: 표현식의 구체적인 표현 방식보다 연산에 집중하는 접근법
  • 재귀적 과정: 복잡한 표현식을 더 작은 부분으로 분해하여 미분

주요 내용

  1. 미분 규칙:

    • 상수 미분: dc/dx = 0
    • 변수 미분: dx/dx = 1, dy/dx = 0 (y ≠ x)
    • 합의 미분: d(u+v)/dx = du/dx + dv/dx
    • 곱의 미분: d(uv)/dx = u(dv/dx) + v(du/dx)
  2. 데이터 추상화 함수:

    • is_variable(e), is_same_variable(v1, v2)
    • is_sum(e), addend(e), augend(e), make_sum(a1, a2)
    • is_product(e), multiplier(e), multiplicand(e), make_product(m1, m2)
  3. 단순화 구현:

    • make_summake_product에서 수치 연산 단순화 포함
    • 예: make_sum(0, x)x, make_product(1, y)y

학습 포인트

  • 데이터 추상화를 통해 알고리즘과 표현을 분리하는 방법 이해
  • 재귀적 문제 해결 전략 훈련
  • 표현식 단순화의 중요성 인지

2. 2.3.3 집합 표현 (Representing Sets)

핵심 개념

  • 집합 연산: is_element_of_set, adjoin_set, union_set, intersection_set
  • 다양한 표현법: 각각 다른 시간 복잡도 특성을 가짐
  • 정보 검색: 집합 개념이 실제 데이터 검색 시스템에 적용되는 방식

주요 내용

  1. 비정렬 리스트 표현:

    • 검색: O(n)
    • 합집합/교집합: O(n²)
  2. 정렬된 리스트 표현:

    • 검색: 평균 O(n/2)
    • 교집합: O(n) (두 집합의 크기 합)
  3. 이진 트리 표현:

    • 균형 트리인 경우 검색: O(log n)
    • entry, left_branch, right_branch 선택자 사용

학습 포인트

  • 데이터 구조에 따른 시간 복잡도 차이 이해
  • 균형 이진 트리의 중요성 인지
  • 추상 데이터 타입으로의 접근법 습득

3. 2.3.4 허프만 인코딩 트리 (Huffman Encoding Trees)

핵심 개념

  • 가변 길이 코드: 빈도가 높은 심볼에 짧은 코드 할당
  • 접두 코드: 어떤 코드도 다른 코드의 접두사가 되지 않음
  • 최적 코드: 허프만 알고리즘이 최적 접두 코드 생성

주요 내용

  1. 허프만 트리 구성:

    • 가장 낮은 빈도의 두 노드를 반복적으로 병합
    • 가중치 합으로 새로운 노드 생성
  2. 트리 표현:

    • 리프 노드: make_leaf(symbol, weight)
    • 일반 노드: make_code_tree(left, right)
  3. 인코딩/디코딩:

    • 인코딩: 트리 루트에서 심볼까지 경로 추적
    • 디코딩: 비트 시퀀스를 따라 트리 이동

학습 포인트

  • 정보 이론과 데이터 압축의 기본 개념 이해
  • greedy 알고리즘의 실제 적용 사례 학습
  • 가중치가 있는 데이터의 효율적 표현 방법 습득

종합 학습 가이드

개념적 이해

  1. 데이터 추상화의 힘 이해하기: 어떻게 추상 인터페이스가 구현 세부사항으로부터 알고리즘을 분리하는지
  2. 재귀적 생각 훈련하기: 복잡한 문제를 더 작은 하위 문제로 분해하는 방법
  3. 시간 복잡도 분석하기: 다른 데이터 구조가 연산 효율성에 미치는 영향

실용적 적용

  1. 직접 구현해보기: 각 데이터 구조(집합, 트리)와 알고리즘(미분, 인코딩)을 직접 구현해보기
  2. 확장练习 풀기: 장末의 연습문제를 통해 이해도 확인하고 확장하기
  3. 실생활 연결하기: 이러한 개념이 실제 프로그래밍(컴파일러, 데이터베이스, 압축 알고리즘)에서 어떻게应用的되는지 고민하기

심화 학습을 위한 질문

  1. 기호 미분 시스템에 새로운 연산(지수, 로그)을 추가하려면 어떻게 해야 할까요?
  2. 균형 이진 트리를 유지하는 다른 방법들은 무엇이 있을까요?
  3. 허프만 코드보다 더 효율적인 압축 알고리즘은 무엇이 있을까요?

이 내용들은 컴퓨터 과학의 근본적인 개념들을 다루고 있으며, 복잡한 시스템을 설계하고 분석하는 데 필수적인 사고 방식을 개발하는 데 도움을 줍니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment