평가 모델

평가 모델 (Evaluation Model)

Racket이 프로그램을 어떻게 평가하는지 그 본질을 살펴봐요. 표현식의 단순화로 시작해 계산에 대한 보증(꼬리 위치, 다중 반환값, 가비지 컬렉션), 그리고 모듈·스레드·매개변수·예외 같은 더 큰 개념까지 이어집니다.

출처: Racket Reference

본문

1.1 평가 모델 (Evaluation Model)

Racket 평가는 값을 얻기 위한 표현식의 단순화로 볼 수 있어요. 예를 들어 초등학생이 1 + 1 = 2로 단순화하듯, Racket 평가도

(+ 1 1) → 2

로 단순화합니다. 화살표 → 는 전통적인 = 대신, 평가가 더 단순한 표현식 쪽으로 특정 방향으로 진행됨을 강조해요. 특히 값(value), 예컨대 숫자 2는 평가가 더 이상 단순화하지 않는 표현식입니다.

1.1.1 하위 표현식 평가와 컨티뉴에이션

일부 단순화는 한 단계 이상을 요구합니다. 예를 들어:

(- 4 (+ 1 1)) → (- 4 2) → 2

값이 아닌 표현식은 항상 두 부분으로 분할할 수 있어요: redex("reducible expression", 단일 단계 단순화에서 변할 수 있는 부분)와 컨티뉴에이션(continuation, redex를 둘러싼 평가 문맥)이 그것입니다. (- 4 (+ 1 1))에서 redex는 (+ 1 1)이고, 컨티뉴에이션은 (- 4 [])인데, 여기서 []는 redex가 줄어들면서 그 자리를 차지합니다. 즉 컨티뉴에이션은 redex가 값으로 줄어든 뒤 어떻게 "계속"할지를 말해줘요.

어떤 표현식을 평가하기 전에 그 하위 표현식 중 일부 또는 전부를 평가해야 합니다. 예를 들어 적용 (- 4 (+ 1 1))에서 -의 적용은 하위 표현식 (+ 1 1)이 줄어들기 전에는 줄어들 수 없어요. 따라서 각 구문 형태의 명세는 (그 하위 표현식 중 일부의) 평가 방법과, 그 결과를 결합해 폼을 줄여 없애는 방법을 명시합니다.

표현식의 다이내믹 범위(dynamic extent)는, 그 표현식이 redex를 포함하는 동안의 평가 단계들의 시퀀스입니다.

1.1.2 꼬리 위치 (Tail Position)

표현식 expr1은, expr1이 redex가 될 때마다 그 컨티뉴에이션이 둘러싼 표현식 expr2의 컨티뉴에이션과 같으면, expr2에 대해 꼬리 위치(tail position)에 있습니다.

예를 들어 (+ 1 1)(- 4 (+ 1 1))에 대해 꼬리 위치가 아니에요. 이를 설명하기 위해 C[expr] 표기법을 사용합니다. 이는 어떤 컨티뉴에이션 C[] 자리에 expr을 대입해 만든 표현식입니다:

C[(- 4 (+ 1 1))] → C[(- 4 2)]

이 경우 (+ 1 1)을 줄이기 위한 컨티뉴에이션은 그냥 C가 아니라 C[(- 4 [])]입니다. 위 첫 문단에 명시된 요구 사항이 충족되지 않아요.

대조적으로 (+ 1 1)(if (zero? 0) (+ 1 1) 3)에 대해 꼬리 위치에 있습니다. 어떤 컨티뉴에이션 C에 대해서도

C[(if (zero? 0) (+ 1 1) 3)] → C[(if #t (+ 1 1) 3)] → C[(+ 1 1)]

이기 때문입니다. 첫 문단의 요구 사항이 충족돼요. 이 감소 시퀀스의 단계들은 if의 정의에 의해 구동되고, 컨티뉴에이션 C에 의존하지 않습니다. if 폼의 "then" 분기는 항상 if 폼에 대해 꼬리 위치에 있어요. if#f에 대한 유사한 감소 규칙 덕분에 if 폼의 "else" 분기도 꼬리 위치에 있습니다.

꼬리 위치 명세는 계산의 점근적 공간 소비에 대한 보증을 제공합니다. 일반적으로 꼬리 위치 명세는 if 같은 각 구문 형태의 설명과 함께 다닙니다.

1.1.3 다중 반환값 (Multiple Return Values)

Racket 표현식은 여러 값을 평가할 수 있는데, 이는 프로시저가 여러 인자를 받을 수 있다는 사실과의 대칭을 제공하기 위해서예요.

대부분의 컨티뉴에이션은 특정 개수의 결과 값을 기대하지만, 어떤 컨티뉴에이션은 임의의 개수를 받을 수 있어요. 실제로 (+ [] 1) 같은 대부분의 컨티뉴에이션은 단일 값을 기대합니다. 컨티뉴에이션 (let-values ([(x y) []]) expr)은 두 개의 결과 값을 기대하며, 첫 번째 결과는 body expr에서 x를 대체하고 두 번째 결과는 y를 대체해요. 컨티뉴에이션 (begin [] (+ 1 2))은 결과를 무시하므로 어떤 개수의 결과 값도 받습니다.

일반적으로 구문 형태의 명세는 그것이 만드는 값의 개수와, 각 하위 표현식에서 기대하는 개수를 나타냅니다. 게다가 일부 프로시저(특히 values)는 여러 값을 만들고, 일부 프로시저(특히 call-with-values)는 내부적으로 특정 개수의 값을 받는 컨티뉴에이션을 만듭니다.

1.1.4 최상위 변수 (Top-Level Variables)

x = 10이 주어지면, 대수학을 배우는 학생은 x + 1을 다음과 같이 단순화합니다:

x + 1 = 10 + 1 = 11

Racket은 거의 같은 방식으로 동작합니다. 평가 중에 필요할 때 대입을 위해 사용할 수 있는 최상위 변수 집합(변수와 위치(Variables and Locations) 참고)이 있기 때문이에요. 예를 들어

(define x 10)

이 주어지면

(+ x 1) → (+ 10 1) → 11

Racket에서는 정의가 만들어지는 방식이 사용되는 방식만큼이나 중요해요. 따라서 Racket 평가는 정의와 현재 표현식을 모두 추적하고, define 같은 폼을 평가하는 것에 대응해 정의 집합을 확장합니다.

그러면 각 평가 단계는 현재 정의/프로그램 집합을 새 정의/프로그램 집합으로 변환합니다. define이 정의 집합으로 옮겨지기 전에, 그 표현식(즉 우변)이 값으로 줄어들어야 해요. (좌변은 표현식 위치가 아니므로 평가되지 않습니다.)

defined:  (비어 있음)
evaluate: (begin (define x (+ 9 1)) (+ x 1))
→ defined:  (비어 있음)
  evaluate: (begin (define x 10) (+ x 1))
→ defined:  (define x 10)
  evaluate: (begin (void) (+ x 1))
→ defined:  (define x 10)
  evaluate: (+ x 1)
→ defined:  (define x 10)
  evaluate: (+ 10 1)
→ defined:  (define x 10)
  evaluate: 11

set!을 사용하면 프로그램이 기존 최상위 변수와 연관된 값을 바꿀 수 있어요:

defined:  (define x 10)
evaluate: (begin (set! x 8) x)
→ defined:  (define x 8)
  evaluate: (begin (void) x)
→ defined:  (define x 8)
  evaluate: x
→ defined:  (define x 8)
  evaluate: 8

1.1.5 객체와 명령적 갱신 (Objects and Imperative Update)

최상위 변수의 명령적 갱신을 위한 set!에 더해, 다양한 프로시저가 복합 데이터 구조 안의 요소를 수정할 수 있게 해요. 예를 들어 vector-set!는 벡터의 내용을 수정합니다.

데이터에 대한 그러한 수정을 설명하려면, 표현식의 결과인 값(value)과 실제로 데이터를 담는 객체(object)를 구별해야 합니다.

불리언, void, 작은 정확한 정수 같은 몇몇 종류의 객체는 직접 값으로 쓰일 수 있어요. 그러나 더 일반적으로 값은 다른 어딘가에 저장된 객체에 대한 참조입니다. 예를 들어 값은 첫 번째 슬롯에 값 10을 담고 있는 특정 벡터를 가리킬 수 있어요. 객체가 어떤 값 하나를 통해 수정되면, 그 수정은 그 객체를 참조하는 모든 값을 통해 보입니다.

평가 모델에서는 정의 집합처럼, 객체 집합이 각 평가 단계와 함께 옮겨져야 해요. vector 같은 객체를 만드는 연산은 객체 집합에 추가합니다:

objects:  (비어 있음)
defined:  (비어 있음)
evaluate: (begin (define x (vector 10 20)) (define y x) (vector-set! x 0 11) (vector-ref y 0))
→ objects:  (define <o1> (vector 10 20))
  defined:  (비어 있음)
  evaluate: (begin (define x <o1>) (define y x) (vector-set! x 0 11) (vector-ref y 0))
→ ... (x와 y가 모두 <o1>으로 치환됨) ...
→ objects:  (define <o1> (vector 11 20))
  defined:  (define x <o1>) (define y <o1>)
  evaluate: (vector-ref <o1> 0)
→ objects:  (define <o1> (vector 11 20))
  defined:  (define x <o1>) (define y <o1>)
  evaluate: 11

여기서 (vector-set! x 0 11)(vector-set! <o1> 0 11)로 바뀌어 객체 <o1>을 수정하고, 결과적으로 (vector-ref y 0)11을 만들게 됩니다. (전체 단계 테이블에서 yx와 같은 객체 <o1>을 참조함에 따라 vector-set!의 수정이 vector-ref를 통해 보이는 것을 확인할 수 있어요.)

최상위 변수와 객체 참조의 구별은 중요해요. 최상위 변수는 값이 아니므로 평가되어야 합니다. 변수 표현식이 평가될 때마다 현재 정의 집합에서 변수의 값이 추출됩니다. 반대로 객체 참조는 값이므로 더 평가할 필요가 없습니다. 위 평가 단계들은 객체 참조를 변수 이름과 구별하기 위해 꺾쇠를 쓴 <o1>로 나타냅니다.

객체 참조는 텍스트 기반 소스 프로그램에 직접 나타날 수 없어요. 그러나 datum->syntax로 만든 프로그램 표현은 기존 객체에 대한 직접 참조를 내장할 수 있습니다.

1.1.6 가비지 컬렉션 (Garbage Collection)

가비지 컬렉션 관련 함수는 메모리 관리를 보세요.

프로그램 상태

objects:  (define <o1> (vector 10 20)) (define <o2> (vector 0))
defined:  (define x <o1>)
evaluate: (+ 1 x)

에서 평가는 <o2>에 의존할 수 없어요. <o2>는 평가할 프로그램의 일부가 아니고, 프로그램이 접근할 수 있는 어떤 정의에서도 참조되지 않기 때문입니다. 이 객체는 도달 불가능(reachable)하지 않다고 말합니다. 따라서 객체 <o2>는 가비지 컬렉션에 의해 프로그램 상태에서 제거될 수 있어요.

몇몇 특별한 복합 데이터 타입은 객체에 대한 약한 참조(weak reference)를 유지합니다. 그러한 약한 참조는 나머지 계산에 대해 어떤 객체가 도달 가능한지 판정할 때 가비지 컬렉터가 특별히 취급합니다. 객체가 약한 참조로만 도달 가능하면 객체는 회수될 수 있고, 약한 참조는 다른 값(보통 #f)으로 교체됩니다.

특별한 경우로 fixnum은 가비지 컬렉터에 의해 항상 도달 가능한 것으로 간주됩니다. 많은 다른 값은 구현과 사용 방식 때문에 항상 도달 가능해요: Latin-1 범위의 문자는 항상 도달 가능한데, equal?인 Latin-1 문자는 항상 eq?이고, 모든 Latin-1 문자는 내부 모듈이 참조하기 때문입니다. 마찬가지로 null, #t, #f, eof, #<void>는 항상 도달 가능합니다. quote가 만든 값은 quote 표현식 자체가 도달 가능한 동안 도달 가능하게 유지됩니다.

1.1.7 프로시저 적용과 지역 변수

f(x) = x + 10이 주어지면, 대수학을 배우는 학생은 f(7)을 다음과 같이 단순화합니다:

f(7) = 7 + 10 = 17

이 단순화의 핵심 단계는 정의된 함수 f의 body를 가져와 각 x를 실제 값 7로 대체하는 것입니다.

Racket 프로시저 적용은 거의 같은 방식으로 동작합니다. 프로시저는 객체이므로 (f 7)을 평가하는 것은 변수 조회에서 시작해요:

objects:  (define <p1> (lambda (x) (+ x 10)))
defined:  (define f <p1>)
evaluate: (f 7)
→ objects:  (define <p1> (lambda (x) (+ x 10)))
  defined:  (define f <p1>)
  evaluate: (<p1> 7)

그러나 대수학과 달리 프로시저 인자 변수와 연관된 값은 set!을 사용해 프로시저 body 안에서 바뀔 수 있어요. 예를 들어 (lambda (x) (begin (set! x 3) x))입니다. 인자 변수 x와 연관된 값이 바뀔 수 있으므로, 프로시저를 처음 적용할 때 값만 대입해 x 자리에 넣을 수는 없습니다.

우리는 함수로 선언된 인자 변수 이름을 가리켜 "parameter variable"이라는 용어를 쓰지 않아요. 이 선택은 매개변수(parameters)와의 혼동을 피하기 위해서입니다.

대신 각 적용마다 각 변수에 대해 새 위치(location)가 만들어집니다. 인자 값이 위치에 놓이고, 프로시저 body 안의 변수 각 인스턴스가 새 위치로 대체됩니다:

evaluate: (<p1> 7)
→ objects:  (define <p1> (lambda (x) (+ x 10)))
  defined:  (define f <p1>) (define xloc 7)
  evaluate: (+ xloc 10)
→ objects:  (define <p1> (lambda (x) (+ x 10)))
  defined:  (define f <p1>) (define xloc 7)
  evaluate: (+ 7 10)
→ objects:  (define <p1> (lambda (x) (+ x 10)))
  defined:  (define f <p1>) (define xloc 7)
  evaluate: 17

위치는 최상위 변수와 같지만, 위치가 생성될 때 (개념적으로) 이전에 사용된 적이 없고 다시 생성되거나 직접 접근할 수 없는 이름을 사용합니다.

이런 식으로 위치를 생성한다는 것은, set!이 인자 변수를 포함한 지역 변수에 대해 최상위 변수와 같은 방식으로 동작한다는 뜻이에요. set! 폼이 평가될 때쯤이면 지역 변수는 항상 위치로 대체되어 있기 때문입니다:

evaluate: (f 7)
→ objects:  (define <p1> (lambda (x) (begin (set! x 3) x)))
  defined:  (define f <p1>) (define xloc 7)
  evaluate: (begin (set! xloc 3) xloc)
→ objects:  (define <p1> (lambda (x) (begin (set! x 3) x)))
  defined:  (define f <p1>) (define xloc 3)
  evaluate: (begin (void) xloc)
→ objects:  (define <p1> (lambda (x) (begin (set! x 3) x)))
  defined:  (define f <p1>) (define xloc 3)
  evaluate: xloc
→ objects:  (define <p1> (lambda (x) (begin (set! x 3) x)))
  defined:  (define f <p1>) (define xloc 3)
  evaluate: 3

프로시저 적용의 위치 생성 및 대체 단계는 인자가 값이어야 요구합니다. 따라서 ((lambda (x) (+ x 10)) (+ 1 2))에서는 하위 표현식 (+ 1 2)이 값 3으로 단순화되어야 하고, 그런 다음 3이 x에 대한 위치에 놓일 수 있어요. 즉 Racket은 값-전달(call-by-value) 언어입니다.

let 같은 지역 변수 폼:(let ([x (+ 1 2)]) expr)의 평가는 프로시저 호출과 같습니다. (+ 1 2)가 값을 만든 뒤, exprx의 모든 인스턴스를 대체하는 새 위치에 저장됩니다.

1.1.8 변수와 위치 (Variables and Locations)

변수는 값의 자리 표시자이고, 초기 프로그램의 표현식은 변수를 참조합니다. 최상위 변수는 변수이자 위치입니다. 그 외의 어떤 변수도 항상 실행 시간에 위치로 대체되므로, 표현식 평가는 위치만을 수반합니다. 인자 변수 같은 단일 지역 변수(즉 최상위도 모듈 수준도 아닌 변수)는 서로 다른 적용 중에 서로 다른 위치에 대응할 수 있어요.

예를 들어 프로그램

(define y (+ (let ([x 5]) x) 6))

에서 yx는 모두 변수입니다. y는 최상위 변수이고 x는 지역 변수예요. 이 코드가 평가되면 x가 값 5를 담는 위치가, y가 값 11을 담는 위치가 만들어집니다.

평가 중 변수를 위치로 대체하는 것은 Racket의 어휘 스코프를 구현합니다. x 자리에 xloc을 대체하는 목적상, 모든 변수 바인딩은 서로 구별되는 이름을 사용해야 하므로, 실제로 다른 변수인 어떤 x도 대체되지 않습니다. 그 구별을 보장하는 것이 매크로 확장기의 일입니다. 구문 모델을 보세요. 예를 들어 인자 변수 x가 위치 xloc으로 대체되면, 중첩된 lambda 폼을 포함해 프로시저의 body 전체에 걸쳐 대체됩니다. 결과적으로 변수에 대한 미래의 참조는 항상 같은 위치에 접근합니다.

1.1.9 모듈과 모듈 수준 변수

모듈의 구문은 Modules: module, module*, ...을 보세요.

Racket의 대부분의 정의는 모듈에 있어요. 평가 측면에서 모듈은 본질적으로 정의된 이름 위의 접두사라서, 서로 다른 모듈이 같은 이름을 정의할 수 있어요. 즉 모듈 수준 변수는 평가의 관점에서 최상위 변수와 같습니다.

모듈과 최상위 정의의 한 가지 차이는, 모듈은 그 모듈 수준 정의를 인스턴스화하지 않고도 선언될 수 있다는 점이에요. require의 평가는 선언된 모듈을 인스턴스화(즉 인스턴스화를 촉발)하며, 이는 그 모듈 수준 정의에 대응하는 변수를 만듭니다.

예를 들어 모듈 선언

(module m racket
  (define x 10))

이 주어지면, (require 'm)의 평가는 변수 x를 만들고 그 값으로 10을 설치합니다. 이 x는 어떤 최상위 x 정의와도 무관합니다(마치 고유한 모듈 특정 접두사가 주어진 것처럼).

1.1.9.1 위상 (Phases)

The Racket GuideGeneral Phase Levels도 참고하세요.

위상(phase)의 목적은 실행 시간에 정의된 이름과 확장 시간에 정의된 이름의 필요한 분리를 다루는 것입니다.

모듈은 여러 위상에서 인스턴스화될 수 있어요. 위상은 모듈 이름처럼 모듈 수준 정의의 이름 위에 효과적으로 접두사가 되는 정수입니다. 위상 0은 실행 시간 위상입니다.

최상위 require는 모듈이 위상 0에서 이미 인스턴스화되지 않았다면 위상 0에서 모듈을 인스턴스화합니다. 최상위 (require (for-syntax ....))는 위상 1에서 모듈을 인스턴스화합니다(그 위상에서 이미 인스턴스화되지 않았다면). for-syntaxIntroducing Bindings에서 설명된 대로 추가 프로그램 파싱에 대해 다른 바인딩 효과도 가져요.

모듈 안에서 일부 정의는 이미 위상만큼 이동되어 있어요: begin-for-syntax 폼은 begin과 유사하지만 표현식과 정의를 상대 위상 +1만큼 이동시킵니다. 마찬가지로 define-for-syntax 폼은 define과 유사하지만 정의를 +1만큼 이동시킵니다. 따라서 모듈이 위상 1에서 인스턴스화되면 begin-for-syntax로 정의된 변수들은 위상 2에서 만들어지는 식입니다. 게다가 이 상대 위상은 접두사의 또 다른 계층으로 작용해서, define으로 정의된 xdefine-for-syntax로 정의된 x는 충돌 없이 한 모듈 안에 공존할 수 있어요. begin-for-syntax 폼은 begin-for-syntax 폼 안에 중첩될 수 있는데, 그 경우 내부 정의와 표현식은 상대 위상 +2에 있는 식입니다. 더 높은 위상은 주로 정상 평가보다 프로그램 파싱과 관련이 있습니다.

위상 n에서 인스턴스화된 모듈이 다른 모듈을 require하면, require된 모듈은 먼저 위상 n에서 인스턴스화되고, 그렇게 전이적으로 계속됩니다. 위상 n에서 인스턴스화된 모듈이 다른 모듈 M을 for-syntax로 require하면, M은 위상 n+1에서 사용 가능해지고 나중에 위상 n+1에서 인스턴스화될 수 있어요. (n > 0에 대해) 위상 n에서 사용 가능한 모듈이 다른 모듈 M을 for-template로 require하면, M은 위상 n-1에서 사용 가능해지는 식입니다. 위상 0보다 높은 사용 가능한 모듈의 인스턴스화는 Module Expansion, Phases, and Visits에서 설명된 대로 필요에 따라 촉발됩니다.

모듈 인스턴스화 사이의 마지막 구별은, 위상 1 이상에서 여러 인스턴스화가 존재할 수 있다는 점입니다. 이 인스턴스화들은 모듈 폼의 파싱에 의해 만들어지고, 다시 개념적으로 접두사로 구별됩니다.

최상위 변수는 모듈 안에서와 같은 방식으로 여러 위상에 존재할 수 있어요. 예를 들어 begin-for-syntax 안의 define은 위상 1 변수를 만듭니다. 게다가 make-base-namespaceeval 같은 반영적 연산은 더 높은 위상의 최상위 변수에 접근을 제공하고, 그러한 최상위에 상대적인(require가 촉발한) 모듈 인스턴스화는 대응하게 더 높은 위상에 있습니다.

1.1.9.2 별도 컴파일 보증 (The Separate Compilation Guarantee)

모듈이 컴파일될 때 그 위상 1이 인스턴스화됩니다. 이는 차례로 위상 1을 포함한 다른 위상에서 많은 다른 모듈의 전이적 인스턴스화를 촉발할 수 있어요. Racket은 "별도 컴파일 보증"이라는 이 인스턴스화에 대한 보증을 제공합니다:

컴파일로 인한 모듈 위상 1의 인스턴스화가 Racket 런타임 시스템에 미치는 어떤 효과도 폐기됩니다.

이 보증은 효과(effects)에 관한 것입니다. 효과에는 내부적(internal)과 외부적(external)이라는 두 종류가 있어요.

내부 효과는 변형(mutation)으로 대표됩니다. 변형은 set-box! 같은 함수의 행동으로, 박스에 담긴 값을 바꿉니다. 수정된 박스는 Racket 밖에서 관찰할 수 없으므로 효과가 "내부적"이라고 말합니다. 정의상 내부 효과는 Racket 프로그램 밖에서 감지할 수 없어요.

외부 효과는 입출력(I/O)으로 대표됩니다. I/O는 tcp-connect 같은 함수의 행동으로, 운영 체제와 통신해 Racket을 실행하는 머신 밖으로 네트워크 패킷을 보냅니다. 이 패킷의 전송은 Racket 밖에서, 특히 받는 컴퓨터나 중간의 라우터에서 관찰할 수 있어요. 외부 효과는 Racket 프로그램 밖에서 감지될 수 있도록 존재하며, 물리적 과정을 이용해 종종 감지됩니다.

효과는 더 이상 감지할 수 없을 때 폐기됩니다. 예를 들어 박스의 변형이 3에서 4로 바뀌는 것은, 그것이 바뀌었다는 것을 감지할 수 없게 되어 여전히 3을 담고 있을 때 폐기됩니다. 외부 효과는 본질적으로 Racket 밖에서 관찰 가능하므로 되돌릴 수 없고 폐기될 수 없습니다.

따라서 별도 컴파일 보증은 변형 같은 효과만을 다룹니다. 왜냐하면 그것들이 "물리적 우주"가 아니라 "Racket 런타임 시스템"에만 영향을 주는 효과이기 때문입니다.

Racket 프로그램이 unsafe 함수를 호출할 때마다 Racket 런타임 시스템은 그 효과에 대해 어떤 약속도 하지 않아요. 예를 들어 모든 외부 호출은 ffi/unsafe를 사용하므로 모든 외부 호출은 안전하지 않고, 그 효과는 Racket이 폐기할 수 없습니다.

마지막으로 별도 컴파일 보증은 컴파일 중 위상 1의 인스턴스화만을 다루고, 반영적 메커니즘을 통해 그 위상 1이 require되어 효과에 사용되는 경우 같은 모든 위상 1 인스턴스화를 일반적으로 다루지는 않아요.

이 보증의 실질적 결과는, 효과가 보이지 않기 때문에 어떤 모듈도 자신이 require하는 모듈이 이미 컴파일되었는지를 감지할 수 없다는 것입니다. 따라서 한 모듈의 컴파일을 바꿔 다른 모듈을 이미 컴파일하게 만들 수 없어요. 특히 모듈 A가 모듈 X와 Y의 위상 1 부분에 공유된다면, X가 컴파일되는 동안의 내부 효과는 X와 Y가 Racket 런타임 시스템의 같은 실행 중에 컴파일되든, 컴파일 순서가 어떻든 간에 Y의 컴파일 중에는 보이지 않습니다.

다음 모듈 집합은 이 보증을 보여줍니다. 먼저 box를 통해 효과를 관찰할 수 있는 모듈을 정의합니다:

(module box racket/base
  (provide (all-defined-out))
  (define b (box 0)))

다음으로 이 박스를 사용하고 변형하는 두 개의 구문 변환기를 정의합니다:

(module transformers racket/base
  (provide (all-defined-out))
  (require (for-syntax racket/base 'box))
  (define-syntax (sett stx)
    (set-box! b 2)
    #'(void))
  (define-syntax (gett stx)
    #`#,(unbox b)))

다음으로 이 변환기들을 사용하는 모듈을 정의합니다:

(module user racket/base
  (provide (all-defined-out))
  (require 'transformers)
  (sett)
  (define gott (gett)))

마지막으로 이 변환기들과 user 모듈을 사용하는 두 번째 모듈을 정의합니다:

(module test racket/base
  (require 'box 'transformers 'user)
  (displayln gott)
  (displayln (gett))
  (sett)
  (displayln (gett))
  (displayln (unbox b)))

이 모듈은 다음을 표시합니다:

  • 2 — user 모듈의 (gett)가 2로 확장되었기 때문입니다.

  • 0 — user를 컴파일하는 효과가 폐기되었기 때문입니다.

  • 2 — test 안의 (sett)의 효과가 아직 폐기되지 않았기 때문입니다.

  • 0 — 위상 1에서 sett의 효과가 (unbox b)에서 위상 0으로 b를 사용하는 것과 무관하기 때문입니다.

게다가 이 표시는 이 모듈들이 어떤 순서로 컴파일되든, 동시에 또는 따로 컴파일되든 절대 바뀌지 않아요.

대조적으로 이 모듈들이 b의 값을 파일 시스템의 파일에 저장하도록 바뀌었다면, 프로그램은 2만 표시할 것입니다.

별도 컴파일 보증은 논문 "Composable and Compilable Macros" [Flatt02]와 "Submodules in Racket" [Flatt13]에 정보성 예시를 포함해 더 자세히 설명되어 있어요. 논문 "Advanced Macrology and the implementation of Typed Scheme" [Culpepper07]도 그것이 인상적이고 그 존재 하에 효과ful한 구문 확장을 설계하는 방법에 대한 확장된 예를 담고 있습니다.

1.1.9.3 교차 위상 영속 모듈 (Cross-Phase Persistent Modules)

모듈 body에 (#%declare #:cross-phase-persistent) 폼을 포함하는 것 등, 매우 제약된 형식에 맞는 모듈 선언은 교차 위상 영속(cross-phase persistent) 모듈을 만듭니다. 교차 위상 영속 모듈의 모든 위상에 걸친 인스턴스화는 그 모듈의 첫 인스턴스화가 만든 변수들을 공유합니다. 추가로 교차 위상 영속 모듈 인스턴스화는 공통 모듈 선언을 공유할 때 module registry 사이에서도 지속됩니다.

(module cross '#%kernel
  (#%declare #:cross-phase-persistent)
  (#%provide x)
  (define-values (x) (gensym)))
(module noncross '#%kernel
  (#%provide x)
  (define-values (x) (gensym)))
(define ns (current-namespace))
(define (same-instance? mod)
  (namespace-require mod)
  (define a
    (parameterize ([current-namespace (make-base-namespace)])
      (namespace-attach-module-declaration ns mod)
      (namespace-require mod)
      (namespace-variable-value 'x)))
  (define b
    (parameterize ([current-namespace (make-base-namespace)])
      (namespace-attach-module-declaration ns mod)
      (namespace-require mod)
      (namespace-variable-value 'x)))
  (eq? a b))
(same-instance? ''noncross)
; #f
(same-instance? ''cross)
; #t

교차 위상 영속 모듈의 의도는 위상 교차 후에도 인식할 수 있는 값을 지원하는 것입니다. 예를 들어 위상 1에서 실행되는 매크로 변환기가 exn:fail:syntax 인스턴스로 표현된 구문 오류를 발생시킬 때, 그 인스턴스는 구문 오류를 촉발한 eval 또는 expand 호출을 감싸는 위상 0 예외 처리기에 의해 인식될 수 있어요. exn:fail:syntax 구조체 타입이 교차 위상 영속 모듈로 정의되어 있기 때문입니다.

교차 위상 영속 모듈은 다른 교차 위상 영속 모듈만 import하고, 변수를 함수·구조체 타입·관련 함수, 또는 구조체 타입 프로퍼티·관련 함수에 바인딩하는 정의만 담습니다. 교차 위상 영속 모듈은 구문 리터럴(quote-syntax를 통해)이나 변수 참조(#%variable-reference를 통해)를 절대 포함하지 않아요. 교차 위상 영속 모듈 선언의 구문적 명세는 Cross-Phase Persistent Module Declarations을 보세요.

문서화된 모듈은 racket/kernel처럼 교차 위상 영속으로 명시되지 않는 한, 비-교차 위상 영속이라고 가정해야 해요.

1.1.9.4 모듈 재선언 (Module Redeclarations)

모듈이 이미 선언된 이름으로 선언되면, 새 선언의 정의가 옛 선언을 대체하고 확장합니다. 옛 선언의 변수가 새 선언에 대응하는 것이 없으면, 옛 변수는 계속 존재하지만 그 바인딩은 모듈 body의 어휘 정보에 포함되지 않아요. 새 변수 정의가 옛 선언에 대응하는 것이 있으면, 그것은 효과적으로 옛 변수에 할당합니다.

모듈이 재선언되기 전에 현재 네임스페이스의 base phase에서 인스턴스화되었다면, 모듈의 재선언은 즉시 그 위상에서 인스턴스화됩니다.

현재 inspector가 모듈의 선언 inspector(코드 검사기(Code Inspectors) 참고)를 관리하지 않으면 모듈은 재선언될 수 없어요. 마찬가지로 교차 위상 영속 모듈은 재선언될 수 없습니다. 재선언이 성공하더라도, 재선언을 위한 인스턴스화가 상수인 변수를 수정하려고 하면 이전에 인스턴스화된 모듈의 인스턴스화가 실패할 수 있어요(compile-enforce-module-constants 참고).

1.1.9.5 하위 모듈 (Submodules)

최상위 module 폼 안의 module 또는 module* 폼은 하위 모듈(submodule)을 선언합니다. 하위 모듈은 보통 submod 경로로 둘러싼 모듈에 상대적으로 접근됩니다. 하위 모듈은 어떤 깊이로든 중첩될 수 있어요.

하위 모듈이 모듈 안에 어휘적으로 중첩되어 있지만, 반드시 둘러싼 모듈의 바인딩에 직접 접근할 수 있는 것은 아닙니다. 더 구체적으로 module로 선언된 하위 모듈은 둘러싼 모듈에서 require할 수 없지만, 둘러싼 모듈은 하위 모듈을 require할 수 있어요. 대조적으로 module*로 선언된 하위 모듈은 개념적으로 둘러싼 모듈을 따라가므로 둘러싼 모듈에서 require할 수 있지만, 둘러싼 모듈은 하위 모듈을 require할 수 없습니다. 하위 모듈이 둘러싼 모듈에서 import하거나 그 반대가 아니라면, 두 모듈의 visit이나 인스턴스화는 독립적이고, 그 구현은 심지어 서로 다른 시간에 바이트코드 소스에서 로드될 수 있어요.

module로 선언된 하위 모듈은 module로 선언된 어떤 앞선 하위 모듈도 import할 수 있어요. module*로 선언된 하위 모듈은 module*로 선언된 어떤 앞선 모듈과 module로 선언된 어떤 하위 모듈도 import할 수 있습니다.

하위 모듈 선언이 (module* name #f ....) 형식이면, 둘러싼 모듈 본문의 모든 바인딩이 하위 모듈 본문에 보이고, 하위 모듈은 둘러싼 모듈을 암묵적으로 import합니다. 하위 모듈은 둘러싼 모듈에서 상속한 어떤 바인딩도 provide할 수 있어요.

1.1.10 컨티뉴에이션 프레임과 마크

컨티뉴에이션-마크 폼과 함수는 Continuation Marks를 보세요.

모든 컨티뉴에이션 C는 컨티뉴에이션 프레임 C1, C2, ..., Cn으로 분할될 수 있어서 C = C1[C2[...[Cn]]]이고, 어떤 프레임 Ci도 더 작은 컨티뉴에이션으로 분할될 수 없어요. 평가 단계는 보통 한 번에 하나씩, 현재 컨티뉴에이션에 프레임을 추가하고 제거합니다.

각 프레임은 개념적으로 컨티뉴에이션 마크(continuation mark) 집합으로 주석이 달려 있어요. 마크는 키와 그 값으로 구성됩니다. 키는 임의의 값이고, 각 프레임은 주어진 키에 대해 최대 하나의 마크를 포함합니다. 다양한 연산이 컨티뉴에이션에서 마크를 설정하고 추출하므로, 마크를 사용해 다이내믹 범위에 정보를 붙일 수 있어요. 예를 들어 마크를 사용해 예외가 발생할 때 제시될 "스택 추적"을 위한 정보를 기록하거나, 동적 스코프를 구현할 수 있습니다.

1.1.11 프롬프트, 한정 컨티뉴에이션, 장벽

컨티뉴에이션과 프롬프트 함수는 Continuations를 보세요.

프롬프트(prompt)는 특정 프롬프트 태그(본질적으로 컨티뉴에이션 마크)로 주석이 달린 특별한 종류의 컨티뉴에이션 프레임입니다. 다양한 연산이 redex 위치에서 특정 프롬프트 태그를 가진 가장 가까운 둘러싼 프롬프트까지 컨티뉴에이션의 프레임을 잡는 것을 허용합니다; 그러한 컨티뉴에이션을 때때로 한정 컨티뉴에이션(delimited continuation)이라고 불러요. 다른 연산들은 현재 컨티뉴에이션을 잡힌 컨티뉴에이션(구체적으로 합성 가능한 컨티뉴에이션)으로 확장하는 것을 허용합니다. 또 다른 연산들은 특정 태그를 가진 가장 가까운 둘러싼 프롬프트로 계산을 중단(abort)하거나, 가장 가까운 둘러싼 프롬프트까지의 컨티뉴에이션을 다른 것으로 대체합니다. 한정 컨티뉴에이션이 잡히면 관련 프레임과 연관된 마크도 함께 잡힙니다.

컨티뉴에이션 장벽(continuation barrier)은 현재 컨티뉴에이션을 다른 것으로 대체하는 특정을 금지하는 또 다른 종류의 컨티뉴에이션 프레임입니다. 구체적으로 컨티뉴에이션은, 교체가 어떤 컨티뉴에이션 장벽도 도입하지 않을 때만 다른 것으로 대체될 수 있어요. 따라서 컨티뉴에이션 장벽은 장벽으로 보호되는 컨티뉴에이션으로의 "아래로 점프"를 방지합니다. 특정 연산들은 장벽을 자동으로 설치합니다; 특히 예외 처리기가 호출될 때 컨티뉴에이션 장벽은 처리기의 컨티뉴에이션이 예외 지점을 지나 컨티뉴에이션을 잡는 것을 금지합니다.

탈출 컨티뉴에이션(escape continuation)은 본질적으로 파생된 개념입니다. 그것은 탈출 목적의 프롬프트와 마크 수집 목적의 컨티뉴에이션을 결합합니다. 이름이 암시하듯 탈출 컨티뉴에이션은 잡힌 지점으로 중단하는 데만 사용됩니다.

1.1.12 스레드 (Threads)

스레드와 동기화 함수는 Concurrency and Parallelism을 보세요.

Racket은 여러 평가 스레드를 지원합니다. 스레드는 한 스레드가 협력 없이 다른 스레드를 선점할 수 있다는 의미에서 동시에 실행됩니다. 그러나 기본적으로 스레드는 (적어도 같은 place 안에서) 다른 코루틴 스레드들과 같은 프로세서(즉 같은 근본적인 운영 체제 프로세스와 스레드)에서 실행되는 코루틴 스레드입니다.

스레드는 thread 같은 함수에 의해 명시적으로 만들어집니다. 평가 모델 측면에서 각 평가 단계는 단일 표현식이 아니라, 스레드당 하나씩인 여러 동시 표현식(최대 여러 개)을 실제로 다룹니다. 그 표현식들은 모두 같은 객체와 최상위 변수를 공유하므로 공유 상태를 통해 통신할 수 있고, 코루틴 스레드 사이에는 순차 일관성 [Lamport79]이 보장됩니다(즉 결과는 스레드에 걸친 모든 평가 단계에 부과된 어떤 전역 순서와 일치합니다). 대부분의 평가 단계는 단일 스레드의 단일 단계이지만, 특정 동기화 기본 요소는 여러 스레드가 한 단계에서 함께 진행되도록 요구합니다; 예를 들어 channel을 통한 값의 교환은 두 스레드에서 동시에 진행됩니다.

달리 명시되지 않는 한, Racket이 제공하는 모든 상수 시간 프로시저와 연산은 원자적(atomic)이라는 의미에서 스레드 안전합니다: 그것들은 단일 평가 단계로 발생합니다. 예를 들어 set!은 모든 스레드에 대해 원자적 동작으로 변수에 할당하므로, 어떤 스레드도 "절반 할당된" 변수를 볼 수 없어요. 마찬가지로 vector-set!은 벡터에 원자적으로 할당합니다. 하위 표현식이 있는 set! 표현식의 평가는 반드시 원자적인 것은 아니라는 점을 주의하세요. 하위 표현식 평가가 별도의 평가 단계를 수반하기 때문입니다. 오직 할당 동작 자체(하위 표현식이 평가되어 값을 얻은 뒤 일어나는)만 원자적입니다. 마찬가지로 프로시저 적용은, 프로시저 자체가 원자적 동작을 수행하더라도 원자적이지 않은 여러 단계를 수반할 수 있어요.

hash-set! 프로시저는 원자적이지 않지만, 테이블은 잠금으로 보호됩니다; 자세한 내용은 Hash Tables를 보세요. 포트 연산은 일반적으로 원자적이지 않지만, 한 스레드가 입력 포트에서 소비한 바이트가 다른 스레드에게도 반환되지 않는다는 의미에서 스레드 안전하고, port-commit-peekedwrite-bytes-avail 같은 프로시저는 구체적인 동시성 보증을 제공합니다.

모든 스레드가 공유하는 상태에 더해, 각 스레드는 스레드 셀(thread cell)을 통해 접근되는 자신만의 전용 상태를 가져요. 스레드 셀은 평범한 변형 가능 객체와 유사하지만, 스레드 셀 안 값의 변경은 같은 스레드에서 그 셀에서 값을 추출할 때만 보입니다. 스레드 셀은 보존(preserved)될 수 있습니다; 새 스레드가 만들어질 때 보존된 스레드 셀에 대한 만드는 스레드의 값이 그 만들어진 스레드의 셀의 초기 값 역할을 해요. 비보존 스레드 셀에 대해 새 스레드는 다른 모든 스레드와 같은 초기 값(스레드 셀이 만들어질 때 지정된)을 봅니다.

병렬 스레드(parallel thread)는 코루틴 스레드와 같지만 다른 프로세서(즉 다른 근본적인 운영 체제 스레드)에서 실행될 수 있어요. 병렬 스레드는 그 값이 'own 또는 병렬 스레드 풀인 #:pool 인자로 thread를 호출해 만들 수 있습니다. Racket이 제공하는 연산은 병렬 스레드에서도 스레드 안전하게 유지되지만, 연산과 스레드에 걸친 순차 일관성은 보장되지 않아요. 두 병렬 스레드가 상태를 공유하면, 공유 상태에 대한 각 읽거나 쓰는 연산은 가상 메모리 수준의 읽기 또는 쓰기 연산에 대응합니다. Racket은 그런 보증을 명시하는 연산(예: box-cas!)의 경우를 제외하고는, 가상 메모리 수준이나 그 아래에서 수행될 수 있는 재정렬에 대해 추가 보증을 강제하지 않아요. 즉 호스트 머신의 메모리 모델이 병렬 스레드에 걸친 관찰로 노출될 수 있습니다.

공유 상태의 가능성은 일부 연산에 비용을 부과하며, 특히 병렬 스레드 간 공유의 경우 그러하고, 근본적인 기본 요소가 더 비관적인 모드에 빠질 때 병렬 스레드를 사용하면 코루틴 스레드를 사용하는 것보다 계산을 쉽게 더 느려지게 할 수 있어요. Futuresplaces는 공유 제약과 성능에서 다른 절충을 제공하는 병렬 스레드의 대안입니다. Futures는 때때로 병렬로 실행되는 연산을 제한함으로써 더 나은 성능을 얻고, 결과적으로 더 저렴하게 만들어지고 완료될 수 있으며, 병렬 스레드가 느려지게 되는 경우에 더 일관되게 코루틴 성능으로 되돌아갈 수 있어요. Places는 때때로 (운영 체제 수준의 별도 프로세스와 다소 비슷하게) 공유를 제한함으로써 더 나은 성능을 얻어, 연산이 더 낙관적으로 진행될 수 있게 합니다. 한 place는 순차 일관성으로 스케줄하는 자신만의 코루틴 스레드 집합을 가지지만, 다른 place의 코루틴 스레드와 병렬로 실행될 수 있어요. futures와 places 둘 다 공유 상태의 가능성을 포함하고, 병렬 스레드와 같은 종류의 약한 순서를 연산에 가집니다.

1.1.13 매개변수 (Parameters)

매개변수 폼과 함수는 Parameters를 보세요.

매개변수(parameter)는 본질적으로 Racket에서 파생된 개념입니다; 컨티뉴에이션 마크와 스레드 셀 관점에서 정의됩니다. 그러나 매개변수는 또한 "내장"되어 있는데, 일부 기본 프로시저가 매개변수 값을 참조하기 때문입니다. 예를 들어 기본 출력 연산의 기본 출력 스트림은 매개변수로 지정됩니다.

매개변수는 스레드 특정적이고 컨티뉴에이션 특정적인 설정입니다. 빈 컨티뉴에이션에서 각 매개변수는 보존된 스레드 셀에 대응하고, 대응하는 매개변수 프로시저는 현재 스레드의 스레드 셀 값을 접근하고 설정합니다.

비어 있지 않은 컨티뉴에이션에서 매개변수의 값은, 키가 직접 접근할 수 없는 컨티뉴에이션 마크를 통해 가장 가까운 둘러싼 컨티뉴에이션 프레임과 연관된 매개변수화(parameterization)를 통해 결정됩니다. 매개변수화는 각 매개변수를 보존된 스레드 셀에 매핑하고, 스레드 셀과 현재 스레드의 조합이 매개변수의 값을 산출해요. 매개변수 프로시저는 자신의 매개변수에 대해 관련 스레드 셀을 설정하거나 접근합니다.

parameterizecall-with-parameterization 같은 다양한 연산이 현재 컨티뉴에이션의 프레임에 매개변수화를 설치합니다.

1.1.14 예외 (Exceptions)

예외 폼, 함수, 타입은 Exceptions를 보세요.

예외(exception)는 본질적으로 Racket에서 파생된 개념입니다; 컨티뉴에이션, 프롬프트, 컨티뉴에이션 마크 관점에서 정의됩니다. 그러나 예외는 또한 "내장"되어 있는데, 기본 폼과 프로시저가 예외를 발생시킬 수 있기 때문입니다.

예외를 잡는 예외 처리기는 (키가 직접 접근할 수 없는) 컨티뉴에이션 마크를 통해 컨티뉴에이션 프레임과 연관될 수 있어요. 예외가 발생하면 현재 컨티뉴에이션의 마크가, 그 예외를 처리하기 위해 참조되는 예외 처리기 프로시저의 사슬을 결정합니다. 잡히지 않은 예외를 위한 처리기는 내장 매개변수를 통해 지정됩니다.

예외 처리기의 잠재적 행동 중 하나는 특정 프롬프트 태그로 현재 컨티뉴에이션을 둘러싼 프롬프트까지 중단하는 것입니다. 특히 잡히지 않은 예외의 기본 처리기는 특정 태그로 중단하는데, 그 태그에는 프롬프트가 항상 존재해요. 어떤 새 스레드에 대해서도 컨티뉴에이션의 가장 바깥 프레임에 프롬프트가 설치되기 때문입니다.

1.1.15 커스토디언 (Custodians)

커스토디언 함수는 Custodians를 보세요.

커스토디언(custodian)은 스레드 컬렉션, file-stream port, TCP 포트, TCP listener, UDP 소켓, byte converter, place를 관리합니다. 스레드 등이 만들어질 때마다 그것은 current-custodian 매개변수가 결정한 현재 커스토디언의 관리 아래 놓입니다.

커스토디언은 racket/gui/base의 eventspace도 관리합니다.

루트 커스토디언을 제외하고, 모든 커스토디언 자신은 커스토디언에 의해 관리되므로 커스토디언은 계층을 형성해요. 하위 커스토디언이 관리하는 모든 객체는 그 커스토디언의 소유자도 관리합니다.

커스토디언이 custodian-shutdown-all로 종료될 때, 관리하는 포트·TCP 연결 등을 강제로 즉시 닫고, 자신의 스레드를 종료(또는 일시 중단)합니다. 종료된 커스토디언은 새 객체를 관리할 수 없어요. 현재 커스토디언이 종료된 뒤 관리 자원을 만들려고 하는 프로시저(예: open-input-file, thread)가 호출되면 exn:fail:contract 예외가 발생합니다.

스레드는 여러 관리 커스토디언을 가질 수 있고, thread/suspend-to-kill로 만든 일시 중단된 스레드는 커스토디언이 0개일 수 있어요. 추가 커스토디언은 thread-resume를 통해 스레드와 연관됩니다(스레드 일시 중단·재개·종료 참고). 스레드에 여러 커스토디언이 있으면 custodian-shutdown-all이 반드시 스레드를 죽이는 것은 아닙니다. 대신 종료된 커스토디언은 스레드의 관리 커스토디언 집합에서 제거되고, 관리 집합이 비면 스레드가 종료됩니다.

커스토디언이 관리하는 값은 커스토디언이 반-약하게 유지합니다: 커스토디언이 관리하는 값에 대해 will이 실행될 수 있고, 추가로 약한 해시 테이블, ephemeron, weak box를 통한 약한 참조는 Racket의 BC 구현에서는 버려질 수 있지만 CS 구현에서는 그렇지 않아요. 모든 변형에서 커스토디언은 하위 커스토디언을 약하게만 참조합니다; 하위 커스토디언이 참조되지 않지만 자신만의 하위를 가진다면, 커스토디언은 가비지 컬렉션될 수 있고, 그 시점에 그 하위들은 수집된 커스토디언의 상위(소유자) 커스토디언에 즉시 하위가 됩니다.

커스토디언이 관리하는 다른 개체에 더해, make-custodian-box로 만든 커스토디언 박스는 박스의 커스토디언이 종료될 때까지 박스에 놓인 값을 강하게 붙잡아요. 그러나 커스토디언은 박스 자체를 약하게만 유지하므로, 다른 참조가 없으면 박스와 그 내용은 수집될 수 있어요.

Racket이 커스토디언별 메모리 회계 지원으로 컴파일되면( custodian-memory-accounting-available? 참고), current-memory-use 프로시저는 커스토디언 특정 결과를 보고할 수 있어요. 이 결과는 커스토디언의 관리 값, 특히 스레드에서 도달 가능한 객체가 차지하는 메모리 양을 결정하며, 그 하위 커스토디언의 관리 값도 포함합니다. 객체가 서로의 조상이 아닌 두 커스토디언에서 도달 가능하면, 객체는 임의로 어느 한쪽에 청구되고 선택은 각 컬렉션 후 바뀔 수 있어요; 그러나 커스토디언과 그 후손 모두에서 도달 가능한 객체는, 커스토디언이 하위 커스토디언이나 하위의 스레드를 통해서만 객체에 도달할 수 없는 한, 후손이 아니라 커스토디언에 확실히 청구됩니다. 커스토디언별 회계의 도달 가능성은 약한 참조, 다른 커스토디언이 관리하는 스레드에 대한 참조, 다른 커스토디언에 대한 참조, 다른 커스토디언을 위한 커스토디언 박스에 대한 참조를 포함하지 않아요.

더 알아보기