호출과 함수
컴퓨터 과학의 모든 문제는 간접 계층 하나를 추가하는 것으로 해결할 수 있다. 단, 너무 많은 간접 계층으로 인한 문제는 제외한다.
데이비드 휠러
이번 장은 엄청난 양을 다룹니다. 저는 기능을 잘게 쪼개어 설명하려 노력하지만, 때로는 전체 식사를 한 번에 삼켜야 할 때도 있습니다. 다음 과제는 함수입니다. 함수 선언부터 시작할 수도 있지만, 호출할 수 없다면 그리 유용하지 않습니다. 호출 기능을 먼저 구현할 수도 있지만, 호출할 대상이 없습니다. 그리고 이 둘을 모두 지원하기 위해 VM에서 필요한 모든 런타임 지원은 눈에 보이는 어떤 것에도 연결되지 않는다면 그다지 보람이 없을 것입니다. 그래서 우리는 이 모든 것을 한 번에 다룰 것입니다. 할 일이 많지만, 끝내고 나면 뿌듯함을 느낄 것입니다.
24 . 1함수 객체
VM에서 가장 흥미로운 구조적 변화는 스택 주변에서 일어납니다. 우리는 이미 지역 변수와 임시 변수를 위한 스택을 가지고 있으므로, 어느 정도 준비가 되어 있습니다. 하지만 '호출 스택'에 대한 개념은 아직 없습니다. 더 많은 진전을 이루기 전에 이 부분을 해결해야 합니다. 하지만 먼저 코드를 좀 작성해봅시다. 저는 일단 시작하면 기분이 더 좋아지거든요. 함수를 위한 어떤 종류의 표현도 없이는 많은 것을 할 수 없으므로, 거기서부터 시작하겠습니다. VM의 관점에서 함수란 무엇일까요?
함수는 실행될 수 있는 본문을 가지므로, 이는 곧 바이트코드를 의미합니다. 우리는 전체 프로그램과 모든 함수 선언을 하나의 거대한 모놀리식 Chunk로 컴파일할 수 있습니다. 각 함수는 Chunk 내 코드의 첫 번째 명령어를 가리키는 포인터를 가질 것입니다.
이는 네이티브 코드로 컴파일할 때 하나의 견고한 기계어 코드 덩어리로 끝나는 방식과 비슷합니다. 하지만 우리의 바이트코드 VM에서는 좀 더 높은 수준으로 작업할 수 있습니다. 각 함수에 고유한 Chunk를 부여하는 것이 더 깔끔한 모델이라고 생각합니다. 다른 메타데이터도 필요할 것이므로, 지금 바로 모든 것을 하나의 구조체에 넣어봅시다.
struct Obj* next; };
add after struct Obj
typedef struct { Obj obj; int arity; Chunk chunk; ObjString* name; } ObjFunction;
struct ObjString {
Lox에서 함수는 일급 객체이므로, 실제 Lox 객체여야 합니다. 따라서 `ObjFunction`은 모든 객체 타입이 공유하는 `Obj` 헤더를 가집니다. `arity` 필드는 함수가 예상하는 매개변수의 수를 저장합니다. 그리고 청크 외에도 함수의 이름을 저장합니다. 이는 읽기 쉬운 런타임 오류를 보고하는 데 유용할 것입니다.
이것은 "object" 모듈이 Chunk를 참조해야 했던 첫 번째 경우이므로, include를 추가합니다.
#include "common.h"
#include "chunk.h"
#include "value.h"
문자열과 마찬가지로, C에서 Lox 함수를 더 쉽게 다룰 수 있도록 몇 가지 보조 장치를 정의합니다. 일종의 가난한 자의 객체 지향 방식입니다. 먼저, 새로운 Lox 함수를 생성하는 C 함수를 선언하겠습니다.
uint32_t hash; };
add after struct ObjString
ObjFunction* newFunction();
ObjString* takeString(char* chars, int length);
구현은 다음과 같습니다:
add after allocateObject()
ObjFunction* newFunction() { ObjFunction* function = ALLOCATE_OBJ(ObjFunction, OBJ_FUNCTION); function->arity = 0; function->name = NULL; initChunk(&function->chunk); return function; }
우리는 `ALLOCATE_OBJ()`를 사용하여 메모리를 할당하고 객체의 헤더를 초기화하여 VM이 객체의 타입을 알 수 있도록 합니다. `ObjString`에서 했던 것처럼 인자를 전달하여 함수를 초기화하는 대신, 함수를 일종의 빈 상태—0개의 인자, 이름 없음, 코드 없음—로 설정합니다. 이 부분은 함수가 생성된 후 나중에 채워질 것입니다.
새로운 종류의 객체가 생겼으므로, 열거형에 새로운 객체 타입이 필요합니다.
typedef enum {
in enum ObjType
OBJ_FUNCTION,
OBJ_STRING, } ObjType;
함수 객체 사용이 끝나면, 빌려온 메모리를 운영체제로 반환해야 합니다.
switch (object->type) {
in freeObject()
case OBJ_FUNCTION: { ObjFunction* function = (ObjFunction*)object; freeChunk(&function->chunk); FREE(ObjFunction, object); break; }
case OBJ_STRING: {
이 switch case는 `ObjFunction` 자체와 이 객체가 소유한 다른 메모리를 해제하는 역할을 합니다. 함수는 자신의 청크를 소유하므로, 청크의 소멸자와 유사한 함수를 호출합니다.
Lox는 어떤 객체든 출력할 수 있게 하며, 함수는 일급 객체이므로 함수도 처리해야 합니다.
switch (OBJ_TYPE(value)) {
in printObject()
case OBJ_FUNCTION: printFunction(AS_FUNCTION(value)); break;
case OBJ_STRING:
이것은 다음 함수를 호출합니다:
add after copyString()
static void printFunction(ObjFunction* function) { printf("<fn %s>", function->name->chars); }
함수는 자신의 이름을 알고 있으므로, 출력할 때 그 이름을 말해줄 수 있습니다.
마지막으로, 값을 함수로 변환하는 몇 가지 매크로가 있습니다. 먼저, 값이 실제로 함수인지 확인해야 합니다.
#define OBJ_TYPE(value) (AS_OBJ(value)->type)
#define IS_FUNCTION(value) isObjType(value, OBJ_FUNCTION)
#define IS_STRING(value) isObjType(value, OBJ_STRING)
그것이 참으로 평가된다면, 다음을 사용하여 `Value`를 `ObjFunction` 포인터로 안전하게 캐스팅할 수 있습니다.
#define IS_STRING(value) isObjType(value, OBJ_STRING)
#define AS_FUNCTION(value) ((ObjFunction*)AS_OBJ(value))
#define AS_STRING(value) ((ObjString*)AS_OBJ(value))
이로써 우리의 객체 모델은 함수를 표현하는 방법을 알게 되었습니다. 이제 몸이 좀 풀린 것 같네요. 좀 더 어려운 것에 도전할 준비가 되셨나요?
24 . 2함수 객체로 컴파일하기
현재 우리의 컴파일러는 항상 하나의 단일 청크로 컴파일한다고 가정합니다. 각 함수의 코드가 별도의 청크에 존재하게 되면 상황이 더 복잡해집니다. 컴파일러가 함수 선언에 도달하면, 함수 본문을 컴파일할 때 함수의 청크에 코드를 발행해야 합니다. 함수 본문 끝에서는 컴파일러가 작업 중이던 이전 청크로 돌아가야 합니다.
함수 본문 내부의 코드에는 문제가 없지만, 그 외의 코드는 어떻게 될까요? Lox 프로그램의 '최상위 레벨'도 명령형 코드이며, 이를 컴파일할 청크가 필요합니다. 이 최상위 코드를 자동으로 정의된 함수 안에 배치함으로써 컴파일러와 VM을 단순화할 수 있습니다. 그렇게 하면 컴파일러는 항상 어떤 종류의 함수 본문 내부에 있게 되고, VM은 항상 함수를 호출하여 코드를 실행하게 됩니다. 마치 전체 프로그램이 암묵적인 `main()` 함수로 래핑된 것과 같습니다.
그러면 사용자 정의 함수로 넘어가기 전에, 암묵적인 최상위 함수를 지원하기 위한 재구성을 해봅시다. `Compiler` 구조체부터 시작합니다. 컴파일러가 직접 청크를 가리키는 대신, 현재 구축 중인 함수 객체를 참조하도록 합니다.
typedef struct {
in struct Compiler
ObjFunction* function; FunctionType type;
Local locals[UINT8_COUNT];
또한 작은 `FunctionType` 열거형이 있습니다. 이는 컴파일러가 최상위 코드를 컴파일하는지 아니면 함수 본문을 컴파일하는지 구별할 수 있도록 합니다. 대부분의 컴파일러는 이것에 신경 쓰지 않습니다—그래서 유용한 추상화입니다—하지만 한두 군데에서는 이 구분이 의미를 가집니다. 나중에 그 중 하나를 다룰 것입니다.
add after struct Local
typedef enum { TYPE_FUNCTION, TYPE_SCRIPT } FunctionType;
이제 청크에 쓰는 컴파일러의 모든 부분이 `function` 포인터를 통해 접근해야 합니다. 다행히도, 여러 챕터 전에 `currentChunk()` 함수에 대한 청크 접근을 캡슐화했습니다. 우리는 그것만 고치면 나머지 컴파일러는 문제없이 작동할 것입니다.
Compiler* current = NULL;
add after variable current
replace 5 lines
static Chunk* currentChunk() { return ¤t->function->chunk; }
static void errorAt(Token* token, const char* message) {
현재 청크는 항상 우리가 컴파일 중인 함수가 소유하는 청크입니다. 다음으로, 그 함수를 실제로 생성해야 합니다. 이전에는 VM이 컴파일러에게 코드를 채울 청크를 전달했습니다. 대신, 컴파일러는 사용자 프로그램의 컴파일된 최상위 코드(현재 우리가 지원하는 전부입니다)를 포함하는 함수를 생성하여 반환할 것입니다.
24 . 2 . 1컴파일 시점에 함수 생성하기
컴파일러의 주요 진입점인 `compile()` 함수에서 이 작업을 시작합니다.
Compiler compiler;
in compile()
replace 2 lines
initCompiler(&compiler, TYPE_SCRIPT);
parser.hadError = false;
컴파일러가 초기화되는 방식에 많은 변화가 있습니다. 먼저, 새로운 `Compiler` 필드를 초기화합니다.
function initCompiler()
replace 1 line
static void initCompiler(Compiler* compiler, FunctionType type) { compiler->function = NULL; compiler->type = type;
compiler->localCount = 0;
그런 다음 컴파일할 새로운 함수 객체를 할당합니다.
compiler->scopeDepth = 0;
in initCompiler()
compiler->function = newFunction();
current = compiler;
컴파일러에서 `ObjFunction`을 생성하는 것이 다소 이상하게 보일 수 있습니다. 함수 객체는 함수의 '런타임' 표현이지만, 여기서는 컴파일 시점에 생성하고 있습니다. 함수는 문자열이나 숫자 리터럴과 유사하다고 생각할 수 있습니다. 이는 컴파일 타임과 런타임 세계를 잇는 다리 역할을 합니다. 함수 선언에 도달했을 때, 이들은 실제로 리터럴입니다. 즉, 내장 타입의 값을 생성하는 표기법입니다. 따라서 컴파일러는 컴파일 중에 함수 객체를 생성합니다. 그런 다음 런타임에는 단순히 호출됩니다.
여기 또 다른 이상한 코드 조각이 있습니다:
current = compiler;
in initCompiler()
Local* local = ¤t->locals[current->localCount++]; local->depth = 0; local->name.start = ""; local->name.length = 0;
}
컴파일러의 `locals` 배열은 어떤 스택 슬롯이 어떤 지역 변수나 임시 변수와 연결되는지 추적합니다. 이제부터 컴파일러는 VM 자체의 내부 사용을 위해 스택 슬롯 0을 암묵적으로 할당합니다. 사용자가 이 슬롯을 참조하는 식별자를 작성할 수 없도록 빈 이름을 부여합니다. 이 기능이 유용해질 때 자세히 설명하겠습니다.
이것이 초기화 측면입니다. 코드를 컴파일 완료할 때 다른 쪽에서도 몇 가지 변경 사항이 필요합니다.
function endCompiler()
replace 1 line
static ObjFunction* endCompiler() {
emitReturn();
이전에는 `interpret()`가 컴파일러를 호출할 때, 작성될 `Chunk`를 전달했습니다. 이제 컴파일러가 직접 함수 객체를 생성하므로, 그 함수를 반환합니다. 현재 컴파일러에서 다음과 같이 가져옵니다.
emitReturn();
in endCompiler()
ObjFunction* function = current->function;
#ifdef DEBUG_PRINT_CODE
그리고 `compile()`로 다음과 같이 반환합니다:
#endif
in endCompiler()
return function;
}
이제 이 함수에서 또 다른 수정을 할 좋은 시점입니다. 이전에 우리는 컴파일러 디버깅을 위해 VM이 디스어셈블된 바이트코드를 덤프하도록 진단 코드를 추가했습니다. 생성된 청크가 함수로 래핑되었으므로, 이 기능이 계속 작동하도록 수정해야 합니다.
#ifdef DEBUG_PRINT_CODE
if (!parser.hadError) {
in endCompiler()
replace 1 line
disassembleChunk(currentChunk(), function->name != NULL ? function->name->chars : "<script>");
} #endif
여기서 함수의 이름이 `NULL`인지 확인하는 부분이 보이시나요? 사용자 정의 함수는 이름을 가지지만, 최상위 코드를 위해 생성하는 암묵적인 함수는 이름을 가지지 않습니다. 우리 자신의 진단 코드에서도 이를 우아하게 처리해야 합니다. 이야기가 나온 김에 말인데:
static void printFunction(ObjFunction* function) {
in printFunction()
if (function->name == NULL) { printf("<script>"); return; }
printf("<fn %s>", function->name->chars);
사용자가 최상위 함수에 대한 참조를 얻어 출력하려고 할 방법은 없지만, 전체 스택을 출력하는 우리의 `DEBUG_TRACE_EXECUTION` 진단 코드는 그렇게 할 수 있으며 실제로 그렇게 합니다.
`compile()` 함수로 한 단계 올라가서, 함수 시그니처를 조정합니다.
#include "vm.h"
function compile()
replace 1 line
ObjFunction* compile(const char* source);
#endif
이제 청크를 받는 대신 함수를 반환합니다. 구현부에서는:
function compile()
replace 1 line
ObjFunction* compile(const char* source) {
initScanner(source);
드디어 실제 코드로 넘어갑니다. 함수의 맨 끝을 다음과 같이 변경합니다.
while (!match(TOKEN_EOF)) {
declaration();
}
in compile()
replace 2 lines
ObjFunction* function = endCompiler(); return parser.hadError ? NULL : function;
}
컴파일러에서 함수 객체를 가져옵니다. 컴파일 오류가 없었다면 이를 반환합니다. 그렇지 않으면 `NULL`을 반환하여 오류를 알립니다. 이렇게 하면 VM이 유효하지 않은 바이트코드를 포함할 수 있는 함수를 실행하려고 시도하지 않습니다.
결국 `interpret()`를 업데이트하여 `compile()`의 새로운 선언을 처리해야 하지만, 먼저 다른 변경 사항을 적용해야 합니다.
24 . 3호출 프레임
이제 큰 개념적 도약을 할 시간입니다. 함수 선언과 호출을 구현하기 전에, VM이 이를 처리할 준비를 마쳐야 합니다. 우리가 신경 써야 할 두 가지 주요 문제가 있습니다:
24 . 3 . 1지역 변수 할당하기
컴파일러는 지역 변수를 위한 스택 슬롯을 할당합니다. 프로그램의 지역 변수 세트가 여러 함수에 분산되어 있을 때, 이것이 어떻게 작동해야 할까요?
한 가지 옵션은 지역 변수들을 완전히 분리하는 것입니다. 각 함수는 호출되지 않을 때에도 VM 스택에서 영원히 소유할 자신만의 전용 슬롯 세트를 가질 것입니다. 전체 프로그램의 각 지역 변수는 VM에 자신만 사용하는 메모리 공간을 갖게 됩니다.
믿기 어렵겠지만, 초기의 프로그래밍 언어 구현들은 이런 식으로 작동했습니다. 최초의 Fortran 컴파일러는 각 변수에 대해 정적으로 메모리를 할당했습니다. 명백한 문제는 이것이 매우 비효율적이라는 점입니다. 대부분의 함수는 특정 시점에 호출되고 있지 않으므로, 사용되지 않는 메모리를 계속 점유하는 것은 낭비입니다.
하지만 더 근본적인 문제는 재귀입니다. 재귀를 사용하면 동일한 함수에 대한 여러 호출이 동시에 '활성화'될 수 있습니다. 각 호출은 자신의 지역 변수를 위한 고유한 메모리가 필요합니다. jlox에서는 함수가 호출되거나 블록에 진입할 때마다 환경을 위한 메모리를 동적으로 할당하여 이 문제를 해결했습니다. clox에서는 모든 함수 호출마다 그런 성능 비용을 원하지 않습니다.
대신, 우리의 해결책은 Fortran의 정적 할당과 jlox의 동적 접근 방식의 중간 지점에 있습니다. VM의 값 스택은 지역 변수와 임시 변수가 후입선출(LIFO) 방식으로 동작한다는 관찰에 기반합니다. 다행히도, 함수 호출을 추가하더라도 이 원칙은 여전히 유효합니다. 다음은 예시입니다:
fun first() { var a = 1; second(); var b = 2; } fun second() { var c = 3; var d = 4; } first();
프로그램을 단계별로 실행하면서 각 시점에 어떤 변수가 메모리에 있는지 살펴보세요:
두 호출을 통해 실행이 진행됨에 따라, 모든 지역 변수는 자신보다 나중에 선언된 변수가 자신보다 먼저 버려질 것이라는 원칙을 따릅니다. 이는 호출을 넘어설 때도 마찬가지입니다. 우리는 `a` 사용이 끝나기 전에 `c`와 `d` 사용이 끝날 것임을 압니다. VM의 값 스택에 지역 변수를 할당할 수 있을 것 같습니다.
이상적으로는, 각 변수가 스택의 어디에 배치될지 컴파일 시점에 결정해야 합니다. 이렇게 하면 변수와 작업하는 바이트코드 명령어를 단순하고 빠르게 유지할 수 있습니다. 위 예시에서는 이를 직접적인 방법으로 상상할 수 있지만, 항상 통하는 것은 아닙니다. 다음을 고려해 보세요:
fun first() { var a = 1; second(); var b = 2; second(); } fun second() { var c = 3; var d = 4; } first();
`second()`의 첫 번째 호출에서 `c`와 `d`는 슬롯 1과 2로 들어갑니다. 하지만 두 번째 호출에서는 `b`를 위한 공간을 마련해야 하므로, `c`와 `d`는 슬롯 2와 3에 있어야 합니다. 따라서 컴파일러는 함수 호출을 넘어 각 지역 변수에 대한 정확한 슬롯을 고정할 수 없습니다. 그러나 주어진 함수 내에서는 각 지역 변수의 '상대적' 위치는 고정되어 있습니다. 변수 `d`는 항상 `c` 바로 다음 슬롯에 있습니다. 이것이 핵심 통찰입니다.
함수가 호출될 때, 우리는 스택의 맨 위가 어디에 있을지 알 수 없습니다. 왜냐하면 다양한 컨텍스트에서 호출될 수 있기 때문입니다. 하지만 스택의 맨 위가 어디든, 우리는 모든 함수의 지역 변수가 그 시작점으로부터 어디에 있을지 상대적으로 알 수 있습니다. 따라서 많은 문제와 마찬가지로, 할당 문제를 한 단계의 간접성으로 해결합니다.
각 함수 호출 시작 시, VM은 해당 함수 자신의 지역 변수가 시작되는 첫 번째 슬롯 위치를 기록합니다. 지역 변수를 다루는 명령어는 오늘날처럼 스택의 맨 아래를 기준으로 하는 대신, 그 위치를 기준으로 하는 슬롯 인덱스로 변수에 접근합니다. 컴파일 시점에는 이 상대적 슬롯들을 계산합니다. 런타임에는 함수 호출의 시작 슬롯을 더하여 그 상대적 슬롯을 절대 스택 인덱스로 변환합니다.
마치 함수가 더 큰 스택 내부에 지역 변수를 저장할 수 있는 '창(window)' 또는 '프레임(frame)'을 얻는 것과 같습니다. **호출 프레임(call frame)**의 위치는 런타임에 결정되지만, 해당 영역 내에서 상대적인 위치를 통해 우리는 필요한 것을 찾을 수 있습니다.
함수의 지역 변수가 시작되는 이 기록된 위치의 역사적인 이름은 **프레임 포인터(frame pointer)**입니다. 함수의 호출 프레임의 시작을 가리키기 때문입니다. 때로는 **베이스 포인터(base pointer)**라고도 불리는데, 모든 함수의 변수가 위치하는 스택 슬롯의 베이스를 가리키기 때문입니다.
이것이 우리가 추적해야 할 첫 번째 데이터 조각입니다. 함수를 호출할 때마다 VM은 해당 함수의 변수가 시작되는 첫 번째 스택 슬롯을 결정합니다.
24 . 3 . 2복귀 주소
현재 VM은 `ip` 필드를 증가시키면서 명령어 스트림을 진행합니다. 유일하게 흥미로운 동작은 `ip`를 더 큰 값으로 오프셋하는 제어 흐름 명령어 주변입니다. 함수를 호출하는 것은 꽤 간단합니다—단순히 `ip`를 해당 함수의 청크에 있는 첫 번째 명령어를 가리키도록 설정하면 됩니다. 하지만 함수 실행이 완료되었을 때는 어떻게 될까요?
VM은 함수가 호출된 청크로 돌아가서 호출 직후의 명령어에서 실행을 재개해야 합니다. 따라서 각 함수 호출마다 호출이 완료될 때 다시 점프할 위치를 추적해야 합니다. 이것을 **복귀 주소(return address)**라고 부르는데, VM이 호출 후에 돌아갈 명령어의 주소이기 때문입니다.
다시 말하지만, 재귀 덕분에 단일 함수에 대해 여러 개의 복귀 주소가 있을 수 있으므로, 이것은 함수 자체가 아닌 각 '호출'의 속성입니다.
24 . 3 . 3호출 스택
따라서 각 활성화된 함수 호출—아직 반환되지 않은 각 호출—에 대해, 해당 함수의 지역 변수가 스택의 어디에서 시작하는지, 그리고 호출자가 어디에서 재개해야 하는지를 추적해야 합니다. 우리는 이것과 몇 가지 다른 것들을 새로운 구조체에 넣을 것입니다.
#define STACK_MAX 256
typedef struct { ObjFunction* function; uint8_t* ip; Value* slots; } CallFrame;
typedef struct {
`CallFrame`은 단일 진행 중인 함수 호출을 나타냅니다. `slots` 필드는 VM의 값 스택에서 이 함수가 사용할 수 있는 첫 번째 슬롯을 가리킵니다. C의 이상한 '포인터는 배열의 일종'이라는 특성 덕분에, 이를 배열처럼 취급할 것이므로 복수형 이름을 붙였습니다.
복귀 주소의 구현은 위에서 설명한 것과 약간 다릅니다. 호출받은 함수(callee)의 프레임에 복귀 주소를 저장하는 대신, 호출하는 함수(caller)가 자신의 `ip`를 저장합니다. 함수에서 반환할 때, VM은 호출자의 `CallFrame`에 있는 `ip`로 점프하여 거기서부터 실행을 재개할 것입니다.
여기에는 호출될 함수에 대한 포인터도 넣어두었습니다. 이것을 사용하여 상수와 다른 몇 가지를 찾을 것입니다.
함수가 호출될 때마다 이 구조체 중 하나를 생성합니다. 힙에 동적으로 할당할 수도 있지만, 그것은 느립니다. 함수 호출은 핵심 작업이므로 가능한 한 빨라야 합니다. 다행히도, 변수에 대해 했던 것과 동일한 관찰을 할 수 있습니다: 함수 호출은 스택 의미론을 가집니다. 만약 `first()`가 `second()`를 호출하면, `second()` 호출은 `first()`가 완료되기 전에 완료될 것입니다.
그래서 VM에서는 이 `CallFrame` 구조체들의 배열을 미리 생성하고, 값 배열처럼 스택으로 취급합니다.
typedef struct {
in struct VM
replace 2 lines
CallFrame frames[FRAMES_MAX]; int frameCount;
Value stack[STACK_MAX];
이 배열은 VM에 직접 있었던 `chunk` 및 `ip` 필드를 대체합니다. 이제 각 `CallFrame`은 고유한 `ip`와 실행 중인 `ObjFunction`에 대한 포인터를 가집니다. 거기서부터 함수의 청크에 접근할 수 있습니다.
VM의 새로운 `frameCount` 필드는 `CallFrame` 스택의 현재 높이—진행 중인 함수 호출 수—를 저장합니다. clox를 단순하게 유지하기 위해 배열의 용량은 고정되어 있습니다. 이는 많은 언어 구현에서와 마찬가지로, 처리할 수 있는 최대 호출 깊이가 있다는 것을 의미합니다. clox의 경우 여기에서 정의됩니다:
#include "value.h"
replace 1 line
#define FRAMES_MAX 64 #define STACK_MAX (FRAMES_MAX * UINT8_COUNT)
typedef struct {
또한 값 스택의 크기를 재정의하여 매우 깊은 호출 트리에서도 충분한 스택 슬롯을 확보하도록 합니다. VM이 시작될 때, `CallFrame` 스택은 비어 있습니다.
vm.stackTop = vm.stack;
in resetStack()
vm.frameCount = 0;
}
`vm.h` 헤더가 `ObjFunction`에 접근해야 하므로, include를 추가합니다.
#define clox_vm_h
replace 1 line
#include "object.h"
#include "table.h"
이제 VM의 구현 파일로 넘어갈 준비가 되었습니다. 힘든 작업이 우리 앞에 놓여 있습니다. `ip`를 VM 구조체에서 `CallFrame`으로 옮겼습니다. VM에서 `ip`를 건드리는 모든 코드 라인을 수정하여 이를 처리해야 합니다. 또한 스택 슬롯으로 지역 변수에 접근하는 명령어들도 현재 `CallFrame`의 `slots` 필드를 기준으로 접근하도록 업데이트해야 합니다.
맨 위부터 차근차근 진행해 보겠습니다.
static InterpretResult run() {
in run()
replace 4 lines
CallFrame* frame = &vm.frames[vm.frameCount - 1]; #define READ_BYTE() (*frame->ip++) #define READ_SHORT() \ (frame->ip += 2, \ (uint16_t)((frame->ip[-2] << 8) | frame->ip[-1])) #define READ_CONSTANT() \ (frame->function->chunk.constants.values[READ_BYTE()])
#define READ_STRING() AS_STRING(READ_CONSTANT())
먼저, 메인 바이트코드 실행 함수 내의 지역 변수에 현재 최상위 `CallFrame`을 저장합니다. 그런 다음 바이트코드 접근 매크로를 해당 변수를 통해 `ip`에 접근하는 버전으로 교체합니다.
이제 세심한 주의가 필요한 각 명령어로 넘어갑니다.
case OP_GET_LOCAL: {
uint8_t slot = READ_BYTE();
in run()
replace 1 line
push(frame->slots[slot]);
break;
이전에는 `OP_GET_LOCAL`이 VM의 스택 배열에서 주어진 지역 슬롯을 직접 읽어왔는데, 이는 스택의 맨 아래부터 슬롯을 인덱싱했음을 의미합니다. 이제는 현재 프레임의 `slots` 배열에 접근하며, 이는 해당 프레임의 시작을 기준으로 주어진 번호의 슬롯에 접근한다는 의미입니다.
지역 변수를 설정하는 것도 같은 방식으로 작동합니다.
case OP_SET_LOCAL: {
uint8_t slot = READ_BYTE();
in run()
replace 1 line
frame->slots[slot] = peek(0);
break;
점프 명령어는 VM의 `ip` 필드를 수정하는 데 사용되었습니다. 이제는 현재 프레임의 `ip`에 대해 동일한 작업을 수행합니다.
case OP_JUMP: {
uint16_t offset = READ_SHORT();
in run()
replace 1 line
frame->ip += offset;
break;
조건부 점프도 마찬가지입니다:
case OP_JUMP_IF_FALSE: {
uint16_t offset = READ_SHORT();
in run()
replace 1 line
if (isFalsey(peek(0))) frame->ip += offset;
break;
그리고 뒤로 점프하는 루프 명령어:
case OP_LOOP: {
uint16_t offset = READ_SHORT();
in run()
replace 1 line
frame->ip -= offset;
break;
우리의 VM을 디버깅하는 데 도움이 되도록 각 명령어가 실행될 때마다 출력하는 진단 코드가 있습니다. 이것 또한 새로운 구조에서 작동해야 합니다.
printf("\n");
in run()
replace 2 lines
disassembleInstruction(&frame->function->chunk, (int)(frame->ip - frame->function->chunk.code));
#endif
VM의 `chunk` 및 `ip` 필드를 전달하는 대신, 이제 현재 `CallFrame`에서 읽습니다.
사실 그렇게 나쁘지 않았습니다. 대부분의 명령어는 단순히 매크로를 사용하므로 건드릴 필요가 없었습니다. 다음으로, `run()`을 호출하는 코드로 한 단계 올라가겠습니다.
InterpretResult interpret(const char* source) {
in interpret()
replace 10 lines
ObjFunction* function = compile(source); if (function == NULL) return INTERPRET_COMPILE_ERROR; push(OBJ_VAL(function)); CallFrame* frame = &vm.frames[vm.frameCount++]; frame->function = function; frame->ip = function->chunk.code; frame->slots = vm.stack;
InterpretResult result = run();
드디어 이전에 컴파일러에 적용했던 변경 사항들을 우리가 방금 적용한 백엔드 변경 사항들과 연결할 차례입니다. 먼저, 소스 코드를 컴파일러에 전달합니다. 컴파일러는 컴파일된 최상위 코드를 포함하는 새로운 `ObjFunction`을 반환합니다. 만약 `NULL`을 받으면, 컴파일러가 이미 보고한 컴파일 시간 오류가 있었다는 의미입니다. 이 경우, 아무것도 실행할 수 없으므로 작업을 중단합니다.
그렇지 않으면, 함수를 스택에 저장하고 해당 코드를 실행할 초기 `CallFrame`을 준비합니다. 이제 컴파일러가 스택 슬롯 0을 따로 둔 이유를 알 수 있을 것입니다. 호출되는 함수를 저장하기 위함입니다. 새로운 `CallFrame`에서는 함수를 가리키고, `ip`를 함수의 바이트코드 시작 부분을 가리키도록 초기화하며, 스택 창이 VM의 값 스택 맨 아래에서 시작하도록 설정합니다.
이것으로 인터프리터가 코드 실행을 시작할 준비가 됩니다. 완료 후에는 VM이 하드코딩된 청크를 해제했었습니다. 이제 `ObjFunction`이 해당 코드를 소유하므로 더 이상 그럴 필요가 없어서, `interpret()`의 끝은 단순히 다음과 같습니다:
frame->slots = vm.stack;
in interpret()
replace 4 lines
return run();
}
이전 VM 필드를 참조하는 마지막 코드 조각은 `runtimeError()`입니다. 이 부분은 챕터 후반에 다시 다루겠지만, 지금은 다음과 같이 변경합시다:
fputs("\n", stderr);
in runtimeError()
replace 2 lines
CallFrame* frame = &vm.frames[vm.frameCount - 1]; size_t instruction = frame->ip - frame->function->chunk.code - 1; int line = frame->function->chunk.lines[instruction];
fprintf(stderr, "[line %d] in script\n", line);
VM에서 청크와 `ip`를 직접 읽는 대신, 스택의 최상위 `CallFrame`에서 가져옵니다. 이렇게 하면 함수가 다시 작동하고 이전과 같이 동작할 것입니다.
이 모든 것을 올바르게 수행했다고 가정하면, clox를 다시 실행 가능한 상태로 만들었습니다. 실행해 보면… 이전과 똑같이 작동합니다. 아직 새로운 기능을 추가하지 않았기 때문에 약간 실망스러울 수 있습니다. 하지만 모든 인프라가 갖춰져 있고 이제 사용할 준비가 되었습니다. 이를 활용해 봅시다.
24 . 4함수 선언
호출 표현식을 만들기 전에 호출할 대상이 필요하므로, 함수 선언부터 먼저 하겠습니다. '재미(fun)'는 키워드에서 시작됩니다.
static void declaration() {
in declaration()
replace 1 line
if (match(TOKEN_FUN)) { funDeclaration(); } else if (match(TOKEN_VAR)) {
varDeclaration();
이것이 다음으로 제어를 넘깁니다:
add after block()
static void funDeclaration() { uint8_t global = parseVariable("Expect function name."); markInitialized(); function(TYPE_FUNCTION); defineVariable(global); }
함수는 일급 값이며, 함수 선언은 단순히 새로운 변수에 함수를 생성하고 저장합니다. 따라서 다른 변수 선언과 마찬가지로 이름을 파싱합니다. 최상위 레벨에서의 함수 선언은 함수를 전역 변수에 바인딩합니다. 블록이나 다른 함수 내부에서는 함수 선언이 지역 변수를 생성합니다.
이전 챕터에서 변수가 두 단계로 정의되는 방식을 설명했습니다. 이는 변수 자신의 이니셜라이저 내부에서 변수의 값에 접근할 수 없도록 보장합니다. 변수가 아직 값을 가지지 않기 때문에 그렇게 되면 좋지 않습니다.
함수는 이 문제로 고통받지 않습니다. 함수가 자신의 본문 내에서 자신의 이름을 참조하는 것은 안전합니다. 함수가 완전히 정의되기 전까지는 함수를 호출하고 본문을 실행할 수 없으므로, 변수가 초기화되지 않은 상태를 볼 일은 없을 것입니다. 실제적으로, 재귀적인 지역 함수를 지원하기 위해 이를 허용하는 것이 유용합니다.
이를 위해, 함수 본문을 컴파일하기 전에 이름을 컴파일하자마자 함수 선언의 변수를 '초기화됨'으로 표시합니다. 이렇게 하면 본문 내에서 이름이 오류 없이 참조될 수 있습니다.
하지만 한 가지 확인이 필요합니다.
static void markInitialized() {
in markInitialized()
if (current->scopeDepth == 0) return;
current->locals[current->localCount - 1].depth =
이전에는 우리가 지역 스코프에 있다는 것을 이미 알고 있을 때만 `markInitialized()`를 호출했습니다. 이제 최상위 함수 선언도 이 함수를 호출할 것입니다. 그럴 경우, 초기화할 지역 변수가 없습니다. 함수는 전역 변수에 바인딩됩니다.
다음으로, 함수 자체—매개변수 목록과 블록 본문—를 컴파일합니다. 이를 위해 별도의 헬퍼 함수를 사용합니다. 이 헬퍼 함수는 결과 함수 객체를 스택 맨 위에 남겨두는 코드를 생성합니다. 그 후, `defineVariable()`을 호출하여 그 함수를 이전에 선언한 변수에 다시 저장합니다.
매개변수와 본문을 컴파일하는 코드를 분리한 이유는 나중에 클래스 내부에서 메서드 선언을 파싱할 때 재사용할 것이기 때문입니다. 이것부터 시작하여 점진적으로 구축해봅시다:
add after block()
static void function(FunctionType type) { Compiler compiler; initCompiler(&compiler, type); beginScope(); consume(TOKEN_LEFT_PAREN, "Expect '(' after function name."); consume(TOKEN_RIGHT_PAREN, "Expect ')' after parameters."); consume(TOKEN_LEFT_BRACE, "Expect '{' before function body."); block(); ObjFunction* function = endCompiler(); emitBytes(OP_CONSTANT, makeConstant(OBJ_VAL(function))); }
지금은 매개변수에 대해 걱정하지 않겠습니다. 빈 괄호 쌍 다음에 본문을 파싱합니다. 본문은 왼쪽 중괄호로 시작하며, 여기서 이를 파싱합니다. 그런 다음 기존의 `block()` 함수를 호출하는데, 이 함수는 닫는 중괄호를 포함하여 나머지 블록을 컴파일하는 방법을 알고 있습니다.
24 . 4 . 1컴파일러 스택
흥미로운 부분은 상단과 하단의 컴파일러 관련 내용입니다. `Compiler` 구조체는 어떤 지역 변수가 어떤 슬롯을 소유하는지, 현재 몇 개의 중첩 블록에 있는지 등의 데이터를 저장합니다. 이 모든 것은 단일 함수에만 해당합니다. 하지만 이제 프론트엔드는 서로 중첩된 여러 함수를 컴파일하는 것을 처리해야 합니다.
이를 관리하는 비결은 컴파일되는 각 함수마다 별도의 `Compiler`를 생성하는 것입니다. 함수 선언 컴파일을 시작할 때, C 스택에 새로운 `Compiler`를 생성하고 초기화합니다. `initCompiler()`는 해당 `Compiler`를 현재 컴파일러로 설정합니다. 그런 다음 본문을 컴파일할 때, 바이트코드를 발행하는 모든 함수는 새로운 `Compiler`의 함수가 소유하는 청크에 작성합니다.
함수의 블록 본문 끝에 도달하면 `endCompiler()`를 호출합니다. 이 함수는 새로 컴파일된 함수 객체를 반환하며, 우리는 이를 둘러싸는 함수의 상수 테이블에 상수로 저장합니다. 하지만 잠시만요, 어떻게 둘러싸는 함수로 돌아갈 수 있을까요? `initCompiler()`가 현재 컴파일러 포인터를 덮어썼을 때 이를 잃어버렸습니다.
우리는 중첩된 `Compiler` 구조체들을 스택으로 취급하여 이 문제를 해결합니다. VM의 `Value` 및 `CallFrame` 스택과 달리 배열을 사용하지 않을 것입니다. 대신 연결 리스트를 사용합니다. 각 `Compiler`는 자신을 둘러싸는 함수의 `Compiler`를 가리키며, 최상위 코드의 루트 `Compiler`까지 거슬러 올라갑니다.
} FunctionType;
add after enum FunctionType
replace 1 line
typedef struct Compiler { struct Compiler* enclosing;
ObjFunction* function;
`Compiler` 구조체 내부에서는 `Compiler` `typedef`를 참조할 수 없습니다. 해당 선언이 아직 완료되지 않았기 때문입니다. 대신, 구조체 자체에 이름을 부여하고 그 이름을 필드의 타입으로 사용합니다. C는 좀 이상합니다.
새 `Compiler`를 초기화할 때, 곧 현재가 아니게 될 `Compiler`를 해당 포인터에 캡처합니다.
static void initCompiler(Compiler* compiler, FunctionType type) {
in initCompiler()
compiler->enclosing = current;
compiler->function = NULL;
그런 다음 `Compiler`가 작업을 마치면, 이전 컴파일러를 새로운 현재 컴파일러로 복원함으로써 자신을 스택에서 제거합니다.
#endif
in endCompiler()
current = current->enclosing;
return function;
`Compiler` 구조체를 동적으로 할당할 필요조차 없다는 점에 주목하세요. 각각은 C 스택의 지역 변수로 저장됩니다—`compile()` 또는 `function()` 내에서 말이죠. `Compiler`들의 연결 리스트는 C 스택을 통해 이어집니다. 우리가 무한정한 수의 컴파일러를 가질 수 있는 이유는 우리 컴파일러가 재귀 하강(recursive descent) 방식을 사용하기 때문에, 중첩 함수 선언이 있을 때 `function()`이 재귀적으로 자신을 호출하게 되기 때문입니다.
24 . 4 . 2함수 매개변수
함수에 인수를 전달할 수 없다면 함수는 그다지 유용하지 않으므로, 다음으로 매개변수를 처리해 봅시다.
consume(TOKEN_LEFT_PAREN, "Expect '(' after function name.");
in function()
if (!check(TOKEN_RIGHT_PAREN)) { do { current->function->arity++; if (current->function->arity > 255) { errorAtCurrent("Can't have more than 255 parameters."); } uint8_t constant = parseVariable("Expect parameter name."); defineVariable(constant); } while (match(TOKEN_COMMA)); }
consume(TOKEN_RIGHT_PAREN, "Expect ')' after parameters.");
의미론적으로, 매개변수는 함수 본문의 가장 바깥쪽 렉시컬 스코프에 선언된 지역 변수에 불과합니다. 우리는 기존의 명명된 지역 변수 선언을 위한 컴파일러 지원을 사용하여 매개변수를 파싱하고 컴파일합니다. 이니셜라이저를 갖는 지역 변수와 달리, 여기에는 매개변수의 값을 초기화하는 코드가 없습니다. 함수 호출에서 인자 전달을 할 때, 매개변수가 어떻게 초기화되는지 나중에 살펴보겠습니다.
이와 함께, 파싱하는 매개변수 수를 세어 함수의 인자 수를 기록합니다. 함수와 함께 저장하는 또 다른 메타데이터는 함수의 이름입니다. 함수 선언을 컴파일할 때, 함수의 이름을 파싱한 직후 `initCompiler()`를 호출합니다. 이는 이전 토큰에서 이름을 즉시 가져올 수 있음을 의미합니다.
current = compiler;
in initCompiler()
if (type != TYPE_SCRIPT) { current->function->name = copyString(parser.previous.start, parser.previous.length); }
Local* local = ¤t->locals[current->localCount++];
이름 문자열의 복사본을 생성하는 데 주의를 기울여야 합니다. 렉심은 원본 소스 코드 문자열을 직접 가리킨다는 점을 기억하세요. 해당 문자열은 코드가 컴파일을 마치면 해제될 수 있습니다. 컴파일러에서 생성하는 함수 객체는 컴파일러보다 오래 지속되며 런타임까지 유지됩니다. 따라서 함수 객체는 자신이 유지할 수 있는 힙 할당된 이름 문자열이 필요합니다.
훌륭합니다. 이제 다음과 같이 함수 선언을 컴파일할 수 있습니다:
fun areWeHavingItYet() { print "Yes we are!"; } print areWeHavingItYet;
우리는 그것들로 유용한 어떤 것도 할 수 없습니다.
24 . 5함수 호출
이 섹션이 끝날 무렵에는 흥미로운 동작들을 보게 될 것입니다. 다음 단계는 함수 호출입니다. 보통 이런 식으로 생각하지 않지만, 함수 호출 표현식은 일종의 중위(infix) `(` 연산자입니다. 호출될 대상을 위한 높은 우선순위의 표현식이 왼쪽에 오고—보통 단일 식별자입니다. 그 다음 중간에 `(`가 오고, 쉼표로 구분된 인자 표현식들이 뒤따르며, 마지막으로 `)`로 감싸서 마무리합니다.
이러한 특이한 문법적 관점이 우리 파싱 테이블에 구문을 연결하는 방법을 설명합니다.
ParseRule rules[] = {
add after unary()
replace 1 line
[TOKEN_LEFT_PAREN] = {grouping, call, PREC_CALL},
[TOKEN_RIGHT_PAREN] = {NULL, NULL, PREC_NONE},
파서가 표현식 뒤에 왼쪽 괄호를 만나면, 새로운 파서 함수로 디스패치합니다.
add after binary()
static void call(bool canAssign) { uint8_t argCount = argumentList(); emitBytes(OP_CALL, argCount); }
우리는 이미 `(` 토큰을 소비했으므로, 다음으로 별도의 `argumentList()` 헬퍼를 사용하여 인수를 컴파일합니다. 이 함수는 컴파일한 인수의 개수를 반환합니다. 각 인수 표현식은 호출 준비를 위해 그 값을 스택에 남기는 코드를 생성합니다. 그 후, 새로운 `OP_CALL` 명령어를 발행하여 함수를 호출하고, 인수 개수를 오퍼랜드로 사용합니다.
다음 함수를 사용하여 인수를 컴파일합니다:
add after defineVariable()
static uint8_t argumentList() { uint8_t argCount = 0; if (!check(TOKEN_RIGHT_PAREN)) { do { expression(); argCount++; } while (match(TOKEN_COMMA)); } consume(TOKEN_RIGHT_PAREN, "Expect ')' after arguments."); return argCount; }
이 코드는 jlox에서 익숙할 것입니다. 각 표현식 뒤에 쉼표가 있는 한 인수를 처리합니다. 인수가 모두 소진되면 마지막 닫는 괄호를 소비하고 작업을 마칩니다.
음, 거의 다 됐습니다. jlox에서는 호출에 255개 이상의 인수를 전달하지 못하도록 컴파일 타임 검사를 추가했습니다. 그때 clox도 비슷한 제한이 필요할 것이라고 말했습니다. 이제 그 이유를 알 수 있을 것입니다. 인수 개수를 단일 바이트 오퍼랜드로 바이트코드에 넣기 때문에 255개까지만 가능합니다. 이 컴파일러에서도 이를 확인해야 합니다.
expression();
in argumentList()
if (argCount == 255) { error("Can't have more than 255 arguments."); }
argCount++;
프론트엔드는 여기까지입니다. 이제 백엔드로 넘어가기 전에, 중간에 잠시 들러 새 명령어를 선언합시다.
OP_LOOP,
in enum OpCode
OP_CALL,
OP_RETURN,
24 . 5 . 1인수를 매개변수에 바인딩하기
구현으로 넘어가기 전에, 호출 시점에 스택이 어떻게 생겼는지, 그리고 그 시점부터 무엇을 해야 할지 생각해봐야 합니다. 호출 명령어에 도달했을 때, 우리는 이미 호출될 함수에 대한 표현식과 그 인자들을 실행했습니다. 예를 들어 우리 프로그램이 다음과 같다고 가정해 봅시다:
fun sum(a, b, c) { return a + b + c; } print 4 + sum(5, 6, 7);
만약 `sum()` 호출에 대한 `OP_CALL` 명령어에서 VM을 일시 중지하면, 스택은 다음과 같습니다:
`sum()` 자체의 관점에서 이것을 상상해 보세요. 컴파일러가 `sum()`을 컴파일했을 때, 자동으로 슬롯 0을 할당했습니다. 그 후, 매개변수 `a`, `b`, `c`를 위한 지역 슬롯을 순서대로 할당했습니다. `sum()`을 호출하려면, 호출될 함수로 초기화된 `CallFrame`과 함수가 사용할 수 있는 스택 슬롯 영역이 필요합니다. 그런 다음 함수에 전달된 인수를 수집하여 매개변수에 해당하는 슬롯에 넣어야 합니다.
VM이 `sum()`의 본문을 실행하기 시작할 때, 스택 창이 다음과 같이 보이기를 원합니다:
호출자가 설정하는 인수 슬롯과 호출받는 함수(callee)가 필요로 하는 매개변수 슬롯이 모두 정확히 올바른 순서로 배열되어 있다는 것을 알아차리셨나요? 정말 편리하죠! 이것은 우연이 아닙니다. 각 `CallFrame`이 스택에 대한 자신만의 창을 가진다고 말했을 때, 저는 그 창들이 서로 분리되어야 한다고 말한 적이 없습니다. 다음과 같이 창들이 겹치도록 하는 것을 막을 것은 아무것도 없습니다:
서로 다른 바이트코드 VM과 실제 CPU 아키텍처는 다른 호출 규약(calling conventions)을 가집니다. 이는 인수를 전달하고 복귀 주소를 저장하는 등의 특정 메커니즘을 의미합니다. 제가 여기서 사용하는 메커니즘은 Lua의 깔끔하고 빠른 가상 머신에 기반을 둡니다.
호출자의 스택 맨 위에는 호출되는 함수와 그 뒤를 잇는 인수들이 순서대로 포함됩니다. 호출자는 사용 중인 슬롯들 위에 다른 슬롯이 없다는 것을 압니다. 왜냐하면 인수 표현식을 평가할 때 필요했던 모든 임시 변수들은 이미 버려졌기 때문입니다. 호출받는 함수(callee)의 스택 맨 아래는 매개변수 슬롯이 인수 값들이 이미 있는 위치와 정확히 일치하도록 겹쳐집니다.
이것은 우리가 '인수를 매개변수에 바인딩'하기 위해 어떤 작업도 할 필요가 없다는 것을 의미합니다. 슬롯 간이나 환경 간에 값을 복사하는 작업이 없습니다. 인수들은 이미 있어야 할 정확한 위치에 있습니다. 성능 면에서 이보다 더 좋을 수는 없을 것입니다.
이제 호출 명령어를 구현할 시간입니다.
}
in run()
case OP_CALL: { int argCount = READ_BYTE(); if (!callValue(peek(argCount), argCount)) { return INTERPRET_RUNTIME_ERROR; } break; }
case OP_RETURN: {
우리는 호출되는 함수와 전달된 인수의 개수를 알아야 합니다. 후자는 명령어의 오퍼랜드에서 가져옵니다. 이것은 또한 스택 맨 위에서 인수 슬롯을 세어 스택에서 함수를 찾을 위치를 알려줍니다. 이 데이터를 별도의 `callValue()` 함수로 전달합니다. 만약 이 함수가 `false`를 반환하면, 호출이 어떤 종류의 런타임 오류를 일으켰다는 의미입니다. 이런 일이 발생하면 인터프리터를 중단합니다.
`callValue()`가 성공하면, 호출된 함수를 위한 새로운 프레임이 `CallFrame` 스택에 생성될 것입니다. `run()` 함수는 현재 프레임에 대한 자신만의 캐시된 포인터를 가지고 있으므로, 이를 업데이트해야 합니다.
return INTERPRET_RUNTIME_ERROR;
}
in run()
frame = &vm.frames[vm.frameCount - 1];
break;
바이트코드 디스패치 루프가 해당 `frame` 변수에서 읽기 때문에, VM이 다음 명령어를 실행할 때 새로 호출된 함수의 `CallFrame`에서 `ip`를 읽고 해당 코드로 점프할 것입니다. 이 호출을 실행하는 작업은 여기서 시작됩니다:
add after peek()
static bool callValue(Value callee, int argCount) { if (IS_OBJ(callee)) { switch (OBJ_TYPE(callee)) { case OBJ_FUNCTION: return call(AS_FUNCTION(callee), argCount); default: break; // 호출 불가능한 객체 타입. } } runtimeError("Can only call functions and classes."); return false; }
여기에는 새로운 `CallFrame`을 초기화하는 것 이상의 작업이 진행되고 있습니다. Lox는 동적으로 타입이 지정되므로, 사용자가 다음과 같은 잘못된 코드를 작성하는 것을 막을 수 없습니다:
var notAFunction = 123; notAFunction();
이런 일이 발생하면, 런타임은 안전하게 오류를 보고하고 중단해야 합니다. 그래서 가장 먼저 우리가 호출하려는 값의 타입을 확인합니다. 함수가 아니면 오류를 발생시키고 종료합니다. 그렇지 않으면, 실제 호출은 여기서 일어납니다:
add after peek()
static bool call(ObjFunction* function, int argCount) { CallFrame* frame = &vm.frames[vm.frameCount++]; frame->function = function; frame->ip = function->chunk.code; frame->slots = vm.stackTop - argCount - 1; return true; }
이것은 단순히 스택에 있는 다음 `CallFrame`을 초기화합니다. 호출되는 함수에 대한 포인터를 저장하고, 프레임의 `ip`가 함수의 바이트코드 시작 부분을 가리키도록 합니다. 마지막으로, `slots` 포인터를 설정하여 프레임에 스택에 대한 창을 제공합니다. 여기서의 산술 연산은 스택에 이미 있는 인수들이 함수의 매개변수와 일치하도록 보장합니다:
이상한 작은 `- 1`은 나중에 메서드를 추가할 때 컴파일러가 따로 떼어둔 스택 슬롯 0을 고려하기 위한 것입니다. 매개변수는 슬롯 1에서 시작하므로, 인수와 정렬하기 위해 창을 한 슬롯 일찍 시작하도록 만듭니다.
계속하기 전에, 새로운 명령어를 디스어셈블러에 추가해 봅시다.
return jumpInstruction("OP_LOOP", -1, chunk, offset);
in disassembleInstruction()
case OP_CALL: return byteInstruction("OP_CALL", chunk, offset);
case OP_RETURN:
그리고 한 가지 더 짧은 우회입니다. 이제 `CallFrame`을 초기화하는 편리한 함수가 있으니, 이를 사용하여 최상위 코드 실행을 위한 첫 번째 프레임을 설정할 수도 있습니다.
push(OBJ_VAL(function));
in interpret()
replace 4 lines
call(function, 0);
return run();
좋습니다, 이제 호출로 돌아가서…
24 . 5 . 2런타임 오류 확인
겹치는 스택 창은 호출이 함수의 각 매개변수에 대해 정확히 하나의 인수를 전달한다는 가정 하에 작동합니다. 하지만 Lox는 정적으로 타입이 지정되지 않으므로, 어리석은 사용자가 너무 많거나 너무 적은 인수를 전달할 수 있습니다. Lox에서는 이를 런타임 오류로 정의했으며, 다음과 같이 보고합니다:
static bool call(ObjFunction* function, int argCount) {
in call()
if (argCount != function->arity) { runtimeError("Expected %d arguments but got %d.", function->arity, argCount); return false; }
CallFrame* frame = &vm.frames[vm.frameCount++];
매우 간단합니다. 이것이 우리가 각 함수의 인자 수를 `ObjFunction` 내부에 저장하는 이유입니다.
우리가 보고해야 할 또 다른 오류는 사용자의 어리석음보다는 우리 자신의 문제와 관련이 있습니다. `CallFrame` 배열은 고정된 크기를 가지므로, 깊은 호출 체인이 이를 오버플로시키지 않도록 해야 합니다.
}
in call()
if (vm.frameCount == FRAMES_MAX) { runtimeError("Stack overflow."); return false; }
CallFrame* frame = &vm.frames[vm.frameCount++];
실제로, 프로그램이 이 한계에 근접한다면, 대부분의 경우 폭주하는 재귀 코드에 버그가 있을 가능성이 높습니다.
24 . 5 . 3스택 트레이스 출력하기
런타임 오류에 대한 이야기를 하는 김에, 좀 더 유용한 오류 메시지를 만드는 데 시간을 할애해 봅시다. 런타임 오류에서 멈추는 것은 VM이 정의되지 않은 방식으로 충돌하고 망가지는 것을 막는 데 중요합니다. 하지만 단순히 중단하는 것만으로는 사용자가 오류를 일으킨 코드를 수정하는 데 도움이 되지 않습니다.
런타임 오류 디버깅을 돕는 고전적인 도구는 **스택 트레이스(stack trace)**입니다. 이는 프로그램이 종료될 때 여전히 실행 중이던 각 함수와 실행이 중단된 지점을 출력한 것입니다. 이제 호출 스택이 있고 각 함수의 이름을 편리하게 저장했으므로, 런타임 오류가 발생하여 사용자 경험에 문제를 일으킬 때 전체 스택을 보여줄 수 있습니다. 다음과 같이 보입니다:
fputs("\n", stderr);
in runtimeError()
replace 4 lines
for (int i = vm.frameCount - 1; i >= 0; i--) { CallFrame* frame = &vm.frames[i]; ObjFunction* function = frame->function; size_t instruction = frame->ip - function->chunk.code - 1; fprintf(stderr, "[line %d] in ", function->chunk.lines[instruction]); if (function->name == NULL) { fprintf(stderr, "script\n"); } else { fprintf(stderr, "%s()\n", function->name->chars); } }
resetStack(); }
오류 메시지 자체를 출력한 후, 호출 스택을 맨 위(가장 최근에 호출된 함수)에서 맨 아래(최상위 코드)까지 순회합니다. 각 프레임에 대해, 해당 프레임 함수의 현재 `ip`에 해당하는 줄 번호를 찾습니다. 그런 다음 해당 줄 번호와 함께 함수 이름을 출력합니다.
예를 들어, 이 깨진 프로그램을 실행하면:
fun a() { b(); } fun b() { c(); } fun c() { c("too", "many"); } a();
다음과 같이 출력됩니다:
Expected 0 arguments but got 2. [line 4] in c() [line 2] in b() [line 1] in a() [line 7] in script
그렇게 나쁘지 않죠?
24 . 5 . 4함수에서 반환하기
거의 다 됐습니다. 함수를 호출할 수 있고, VM이 함수를 실행할 것입니다. 하지만 아직 함수에서 반환할 수는 없습니다. 우리는 꽤 오랫동안 `OP_RETURN` 명령어를 가지고 있었지만, 항상 바이트코드 루프를 벗어나기 위한 임시 코드가 포함되어 있었습니다. 이제 실제 구현을 할 때가 왔습니다.
case OP_RETURN: {
in run()
replace 2 lines
Value result = pop(); vm.frameCount--; if (vm.frameCount == 0) { pop(); return INTERPRET_OK; } vm.stackTop = frame->slots; push(result); frame = &vm.frames[vm.frameCount - 1]; break;
}
함수가 값을 반환할 때, 그 값은 스택의 맨 위에 있을 것입니다. 우리는 호출된 함수의 전체 스택 창을 곧 버릴 것이므로, 반환 값을 팝(pop)하여 보관합니다. 그런 다음 반환하는 함수에 대한 `CallFrame`을 버립니다. 만약 그것이 마지막 `CallFrame`이었다면, 최상위 코드 실행을 마쳤다는 의미입니다. 전체 프로그램이 완료되었으므로, 메인 스크립트 함수를 스택에서 팝하고 인터프리터를 종료합니다.
그렇지 않다면, 호출받은 함수(callee)가 매개변수와 지역 변수를 위해 사용하던 모든 슬롯을 버립니다. 여기에는 호출자가 인수를 전달하기 위해 사용했던 동일한 슬롯도 포함됩니다. 이제 호출이 완료되었으므로, 호출자는 더 이상 그 슬롯들을 필요로 하지 않습니다. 이는 스택의 맨 위가 반환하는 함수의 스택 창 시작 부분에 정확히 위치하게 됨을 의미합니다.
새로운, 더 낮은 위치에 반환 값을 다시 스택으로 푸시합니다. 그런 다음 `run()` 함수의 현재 프레임에 대한 캐시된 포인터를 업데이트합니다. 호출을 시작했을 때와 마찬가지로, 바이트코드 디스패치 루프의 다음 반복에서 VM은 해당 프레임에서 `ip`를 읽고, `OP_CALL` 명령어 바로 뒤, 중단했던 지점으로 호출자로 다시 점프하여 실행을 재개할 것입니다.
여기서 함수가 실제로 값을 반환했다고 가정하지만, 함수는 본문의 끝에 도달함으로써 암묵적으로 반환할 수도 있습니다:
fun noReturn() { print "Do stuff"; // No return here. } print noReturn(); // ???
그것도 올바르게 처리해야 합니다. 언어 명세에 따르면 그런 경우에는 암묵적으로 `nil`을 반환합니다. 이를 구현하기 위해 다음을 추가합니다:
static void emitReturn() {
in emitReturn()
emitByte(OP_NIL);
emitByte(OP_RETURN); }
컴파일러는 `emitReturn()`을 호출하여 함수 본문 끝에 `OP_RETURN` 명령어를 작성합니다. 이제 그 전에, 스택에 `nil`을 푸시하는 명령어를 발행합니다. 이로써 함수 호출이 제대로 작동하게 되었습니다! 매개변수도 받을 수 있습니다! 우리가 여기서 무엇을 하고 있는지 아는 것처럼 보입니다.
24 . 6Return 문
암묵적인 `nil` 외의 다른 것을 반환하는 함수를 원한다면 `return` 문이 필요합니다. 이를 작동시켜 봅시다.
ifStatement();
in statement()
} else if (match(TOKEN_RETURN)) { returnStatement();
} else if (match(TOKEN_WHILE)) {
컴파일러가 `return` 키워드를 만나면, 다음으로 이동합니다:
add after printStatement()
static void returnStatement() { if (match(TOKEN_SEMICOLON)) { emitReturn(); } else { expression(); consume(TOKEN_SEMICOLON, "Expect ';' after return value."); emitByte(OP_RETURN); } }
반환 값 표현식은 선택 사항이므로, 파서는 세미콜론 토큰을 찾아 값이 제공되었는지 확인합니다. 반환 값이 없으면, 해당 문은 암묵적으로 `nil`을 반환합니다. 우리는 `OP_NIL` 명령어를 발행하는 `emitReturn()`을 호출하여 이를 구현합니다. 그렇지 않으면, 반환 값 표현식을 컴파일하고 `OP_RETURN` 명령어로 반환합니다.
이것은 우리가 이미 구현한 동일한 `OP_RETURN` 명령어입니다. 새로운 런타임 코드는 필요 없습니다. 이는 jlox와는 상당한 차이입니다. jlox에서는 `return` 문이 실행될 때 스택을 해제하기 위해 예외를 사용해야 했습니다. 이는 중첩된 블록 깊은 곳에서 반환할 수 있었기 때문입니다. jlox가 AST를 재귀적으로 탐색했기 때문에, 우리는 벗어나야 할 많은 자바 메서드 호출이 있었습니다.
우리 바이트코드 컴파일러는 이 모든 것을 평탄화합니다. 파싱 중에는 재귀 하강을 수행하지만, 런타임에는 VM의 바이트코드 디스패치 루프가 완전히 평탄합니다. C 레벨에서는 재귀가 전혀 발생하지 않습니다. 따라서 중첩된 블록 내부에서 반환하는 것도 함수 본문의 끝에서 반환하는 것만큼 간단합니다.
하지만 아직 완전히 끝난 것은 아닙니다. 새로운 `return` 문은 우리가 걱정해야 할 새로운 컴파일 오류를 발생시킵니다. `return`은 함수에서 반환하는 데 유용하지만, Lox 프로그램의 최상위 레벨도 명령형 코드입니다. 거기서 `return`할 수 없어야 합니다.
return "What?!";
우리는 어떤 함수 외부에서든 `return` 문을 갖는 것을 컴파일 오류로 지정했으며, 이를 다음과 같이 구현합니다:
static void returnStatement() {
in returnStatement()
if (current->type == TYPE_SCRIPT) { error("Can't return from top-level code."); }
if (match(TOKEN_SEMICOLON)) {
이것이 우리가 컴파일러에 `FunctionType` 열거형을 추가한 이유 중 하나입니다.
24 . 7네이티브 함수
우리 VM은 점점 더 강력해지고 있습니다. 함수, 호출, 매개변수, 반환 기능이 있습니다. 서로를 흥미로운 방식으로 호출할 수 있는 다양한 함수를 정의할 수 있습니다. 하지만 궁극적으로는 실제로 아무것도 할 수 없습니다. Lox 프로그램이 복잡성과 상관없이 사용자에게 보이는 유일한 기능은 출력입니다. 더 많은 기능을 추가하려면 사용자에게 노출해야 합니다.
프로그래밍 언어 구현은 **네이티브 함수(native functions)**를 통해 현실 세계와 상호작용합니다. 시간을 확인하거나, 사용자 입력을 읽거나, 파일 시스템에 접근하는 프로그램을 작성할 수 있도록 하려면, Lox에서 호출 가능하지만 C로 구현된 네이티브 함수를 추가하여 이러한 기능들을 노출해야 합니다.
언어 수준에서 Lox는 꽤 완성도가 높습니다. 클로저, 클래스, 상속 등 재미있는 기능들을 가지고 있습니다. 장난감 언어처럼 느껴지는 한 가지 이유는 네이티브 기능이 거의 없기 때문입니다. 긴 목록의 네이티브 기능들을 추가함으로써 이를 실제 언어로 만들 수 있습니다.
하지만 수많은 OS 작업을 하나씩 처리하는 것은 사실 교육적으로 그다지 유용하지 않습니다. 하나의 C 코드를 Lox에 바인딩하는 방법을 한 번 보고 나면 개념을 이해할 수 있습니다. 그러나 하나는 봐야 하고, 심지어 단일 네이티브 함수조차 Lox와 C를 연결하기 위한 모든 메커니즘을 구축해야 합니다. 그래서 우리는 그 과정을 거쳐 모든 힘든 작업을 수행할 것입니다. 그런 다음, 모든 것이 완료되면 작동한다는 것을 증명하기 위해 아주 작은 네이티브 함수 하나를 추가할 것입니다.
새로운 메커니즘이 필요한 이유는 구현 관점에서 네이티브 함수가 Lox 함수와 다르기 때문입니다. 네이티브 함수가 호출될 때 `CallFrame`을 푸시하지 않습니다. 해당 프레임이 가리킬 바이트코드 코드가 없기 때문입니다. 네이티브 함수는 바이트코드 청크를 가지지 않습니다. 대신 어떤 방식으로든 네이티브 C 코드를 참조합니다.
clox에서는 네이티브 함수를 완전히 다른 객체 타입으로 정의하여 이를 처리합니다.
} ObjFunction;
add after struct ObjFunction
typedef Value (*NativeFn)(int argCount, Value* args); typedef struct { Obj obj; NativeFn function; } ObjNative;
struct ObjString {
표현은 `ObjFunction`보다 간단합니다. 단순히 `Obj` 헤더와 네이티브 동작을 구현하는 C 함수에 대한 포인터만 있습니다. 네이티브 함수는 인자 수와 스택에 있는 첫 번째 인수에 대한 포인터를 받습니다. 이 포인터를 통해 인수에 접근합니다. 작업이 완료되면 결과 값을 반환합니다.
항상 그렇듯이, 새로운 객체 타입에는 몇 가지 부속품이 따릅니다. `ObjNative`를 생성하기 위해 생성자처럼 동작하는 함수를 선언합니다.
ObjFunction* newFunction();
add after newFunction()
ObjNative* newNative(NativeFn function);
ObjString* takeString(char* chars, int length);
다음과 같이 구현합니다:
add after newFunction()
ObjNative* newNative(NativeFn function) { ObjNative* native = ALLOCATE_OBJ(ObjNative, OBJ_NATIVE); native->function = function; return native; }
생성자는 `ObjNative`에 래핑할 C 함수 포인터를 받습니다. 객체 헤더를 설정하고 함수를 저장합니다. 헤더를 위해 새로운 객체 타입이 필요합니다.
typedef enum {
OBJ_FUNCTION,
in enum ObjType
OBJ_NATIVE,
OBJ_STRING, } ObjType;
VM은 네이티브 함수 객체를 어떻게 할당 해제해야 하는지도 알아야 합니다.
}
in freeObject()
case OBJ_NATIVE: FREE(ObjNative, object); break;
case OBJ_STRING: {
`ObjNative`가 추가 메모리를 소유하지 않으므로 여기에는 많은 것이 없습니다. 모든 Lox 객체가 지원하는 다른 기능은 출력입니다.
break;
in printObject()
case OBJ_NATIVE: printf("<native fn>"); break;
case OBJ_STRING:
동적 타이핑을 지원하기 위해, 값이 네이티브 함수인지 확인하는 매크로가 있습니다.
#define IS_FUNCTION(value) isObjType(value, OBJ_FUNCTION)
#define IS_NATIVE(value) isObjType(value, OBJ_NATIVE)
#define IS_STRING(value) isObjType(value, OBJ_STRING)
그것이 참을 반환한다고 가정하면, 이 매크로는 네이티브 함수를 나타내는 `Value`에서 C 함수 포인터를 추출합니다:
#define AS_FUNCTION(value) ((ObjFunction*)AS_OBJ(value))
#define AS_NATIVE(value) \ (((ObjNative*)AS_OBJ(value))->function)
#define AS_STRING(value) ((ObjString*)AS_OBJ(value))
이 모든 수고 덕분에 VM은 네이티브 함수를 다른 객체처럼 취급할 수 있습니다. 변수에 저장하고, 전달하며, 생일 파티를 열어줄 수도 있습니다. 물론 우리가 실제로 중요하게 여기는 연산은 이들을 호출하는 것입니다. 즉, 호출 표현식에서 왼쪽 피연산자로 사용하는 것입니다.
`callValue()` 함수에 또 다른 타입 케이스를 추가합니다.
case OBJ_FUNCTION:
return call(AS_FUNCTION(callee), argCount);
in callValue()
case OBJ_NATIVE: { NativeFn native = AS_NATIVE(callee); Value result = native(argCount, vm.stackTop - argCount); vm.stackTop -= argCount + 1; push(result); return true; }
default:
호출되는 객체가 네이티브 함수인 경우, 즉시 C 함수를 호출합니다. `CallFrame` 같은 것을 조작할 필요가 없습니다. 그냥 C로 넘겨 결과를 받고, 다시 스택에 넣습니다. 이는 네이티브 함수를 가능한 한 빠르게 만듭니다.
이것으로 사용자들은 네이티브 함수를 호출할 수 있어야 하지만, 호출할 함수가 없습니다. 외부 함수 인터페이스(foreign function interface)와 같은 것이 없으면 사용자들은 자신만의 네이티브 함수를 정의할 수 없습니다. 이는 VM 구현자로서 우리의 역할입니다. Lox 프로그램에 노출될 새로운 네이티브 함수를 정의하기 위한 헬퍼 함수부터 시작하겠습니다.
add after runtimeError()
static void defineNative(const char* name, NativeFn function) { push(OBJ_VAL(copyString(name, (int)strlen(name)))); push(OBJ_VAL(newNative(function))); tableSet(&vm.globals, AS_STRING(vm.stack[0]), vm.stack[1]); pop(); pop(); }
이 함수는 C 함수에 대한 포인터와 Lox에서 알려질 이름을 인자로 받습니다. 우리는 이 함수를 ObjNative로 감싸서 주어진 이름으로 전역 변수에 저장합니다.
아마 스택에 이름과 함수를 푸시하고 팝하는 이유가 궁금할 것입니다. 이상하게 보이죠? 이건 가비지 컬렉션이 개입할 때 걱정해야 할 종류의 문제입니다. `copyString()`과 `newNative()`는 둘 다 동적으로 메모리를 할당합니다. 즉, GC가 있게 되면 이 함수들은 잠재적으로 컬렉션을 트리거할 수 있습니다. 만약 그렇게 된다면, 컬렉터가 이름과 ObjFunction이 아직 사용 중이며 임의로 해제하지 않도록 해야 합니다. 값 스택에 저장하는 것이 이 역할을 수행합니다.
조금 시시하게 들릴 수도 있지만, 이 모든 작업을 거친 후에는 단 하나의 작은 네이티브 함수만 추가할 것입니다.
변수 vm 뒤에 추가
static Value clockNative(int argCount, Value* args) { return NUMBER_VAL((double)clock() / CLOCKS_PER_SEC); }
이 함수는 프로그램이 시작된 이후 경과된 시간을 초 단위로 반환합니다. Lox 프로그램의 벤치마킹에 유용합니다. Lox에서는 이 함수를 `clock()`이라고 명명할 것입니다.
initTable(&vm.strings);
initVM() 안
defineNative("clock", clockNative);
}
C 표준 라이브러리의 `clock()` 함수를 사용하려면 "vm" 모듈에 include가 필요합니다.
#include <string.h>
#include <time.h>
#include "common.h"
정말 많은 내용을 다루었지만, 해냈습니다! 다음 코드를 입력하고 실행해보세요:
fun fib(n) { if (n < 2) return n; return fib(n - 2) + fib(n - 1); } var start = clock(); print fib(35); print clock() - start;
우리는 매우 비효율적인 재귀 피보나치 함수를 작성할 수 있습니다. 더 좋은 점은, 이것이 얼마나 비효율적인지 측정할 수 있다는 것입니다. 물론 피보나치 수를 계산하는 가장 현명한 방법은 아닙니다. 하지만 언어 구현의 함수 호출 지원을 스트레스 테스트하는 좋은 방법입니다. 제 컴퓨터에서 clox로 이 코드를 실행하면 jlox보다 약 5배 빠릅니다. 이는 상당한 개선입니다.
도전 과제
-
바이트코드 루프 내에서 `ip` 필드를 읽고 쓰는 것은 가장 빈번한 작업 중 하나입니다. 현재 우리는 현재 CallFrame에 대한 포인터를 통해 `ip`에 접근하고 있습니다. 이는 포인터 간접 참조를 필요로 하며, CPU가 캐시를 우회하고 주 메모리에 접근하게 만들 수 있습니다. 이는 실제 성능 저하의 원인이 될 수 있습니다.
이상적으로는 `ip`를 네이티브 CPU 레지스터에 보관하는 것이 좋습니다. C는 인라인 어셈블리(inline assembly)로 전환하지 않고는 이를 *요구*할 수 없지만, 컴파일러가 해당 최적화를 수행하도록 코드를 구성할 수는 있습니다. `ip`를 C 지역 변수에 직접 저장하고 `register`로 표시하면, C 컴파일러가 우리의 정중한 요청을 받아들일 가능성이 높습니다.
이는 함수 호출을 시작하고 끝낼 때 지역 `ip`를 다시 올바른 CallFrame에 로드하고 저장하는 데 주의해야 함을 의미합니다. 이 최적화를 구현해보세요. 몇 가지 벤치마크를 작성하여 성능에 어떤 영향을 미치는지 확인하세요. 추가적인 코드 복잡성이 그만한 가치가 있다고 생각하시나요?
-
네이티브 함수 호출이 빠른 한 가지 이유는 함수가 기대하는 만큼의 인자가 전달되었는지 유효성 검사를 하지 않기 때문입니다. 사실 그렇게 해야 합니다. 그렇지 않으면 충분한 인자 없이 네이티브 함수를 잘못 호출하면 함수가 초기화되지 않은 메모리를 읽을 수 있습니다. 인자 수(arity) 검사를 추가하세요.
-
현재 네이티브 함수가 런타임 오류를 알릴 방법이 없습니다. 실제 구현에서는 이 기능을 지원해야 합니다. 왜냐하면 네이티브 함수는 정적 타입 C의 세계에 있지만 동적 타입 Lox 환경에서 호출되기 때문입니다. 예를 들어, 사용자가 `sqrt()`에 문자열을 전달하려고 하면, 해당 네이티브 함수는 런타임 오류를 보고해야 합니다.
이를 지원하도록 네이티브 함수 시스템을 확장하세요. 이 기능이 네이티브 호출의 성능에 어떤 영향을 미치나요?
-
유용하다고 생각하는 네이티브 함수를 더 추가해보세요. 그것들을 사용하여 몇 가지 프로그램을 작성하세요. 무엇을 추가했나요? 추가된 함수들이 언어의 느낌과 실용성에 어떤 영향을 미치나요?