전역 변수
향수처럼 기억을 병에 담아 보관할 수 있는 발명품이 있다면 얼마나 좋을까. 결코 흐려지거나 바래지 않는. 그리고 원할 때면 병의 마개를 열어 그때 그 순간을 다시 살아가는 것처럼 느낄 수 있다면.
대프니 듀 모리에, 레베카
이전 장은 크고 깊이 있는 컴퓨터 과학의 기본 자료구조 중 하나를 탐구하는 긴 여정이었습니다. 이론과 개념이 많았고, 빅오(big-O) 표기법과 알고리즘에 대한 논의도 있었습니다. 이번 장은 지적인 허세가 덜합니다. 새로 배울 거창한 아이디어는 없습니다. 대신, 몇 가지 간단한 공학적 작업들이 기다리고 있습니다. 이 작업들을 마치면 우리 가상 머신(VM)은 변수를 지원할 수 있게 될 것입니다.
사실, 이번 장에서는 전역 변수만 지원할 것입니다. 지역 변수는 다음 장에서 다룹니다. jlox에서는 모든 변수에 동일한 구현 기법을 사용했기 때문에 이 둘을 한 장에 몰아넣을 수 있었습니다. 각 스코프마다 환경(environment) 체인을 만들어 최상위까지 연결했습니다. 이는 상태를 관리하는 방법을 배우는 간단하고 깔끔한 방법이었습니다.
하지만 이는 또한 느립니다. 블록에 들어가거나 함수를 호출할 때마다 새로운 해시 테이블을 할당하는 것은 빠른 VM을 만드는 길이 아닙니다. 변수 사용과 관련된 코드가 얼마나 많은지를 고려할 때, 변수가 느리면 모든 것이 느려집니다. clox에서는 지역 변수에 훨씬 더 효율적인 전략을 사용하여 이를 개선할 것이지만, 전역 변수는 그만큼 쉽게 최적화되지 않습니다.
Lox의 의미론을 간략히 다시 살펴보자면, Lox의 전역 변수는 "지연 바인딩(late bound)"되거나 동적으로 해석됩니다. 이는 전역 변수가 정의되기 전에 이를 참조하는 코드 덩어리를 컴파일할 수 있음을 의미합니다. 정의가 이루어지기 전에 코드가 실행되지 않는 한, 모든 것이 정상적으로 작동합니다. 실제로 이는 함수 본문 안에서 나중에 정의될 변수를 참조할 수 있다는 것을 뜻합니다.
fun showVariable() { print global; } var global = "after"; showVariable();
이런 코드가 이상하게 보일 수도 있지만, 상호 재귀 함수를 정의하는 데 유용합니다. 또한 REPL(Read-Eval-Print Loop)과도 더 잘 작동합니다. 한 줄에 작은 함수를 작성한 다음, 다음 줄에서 그 함수가 사용하는 변수를 정의할 수 있습니다.
지역 변수는 다르게 작동합니다. 지역 변수 선언은 항상 사용되기 전에 발생하기 때문에, VM은 단순한 단일 패스(single-pass) 컴파일러에서도 컴파일 타임에 이를 해석할 수 있습니다. 이를 통해 지역 변수에 더 스마트한 표현 방식을 사용할 수 있게 될 것입니다. 하지만 이는 다음 장에서 다룰 내용입니다. 지금은 전역 변수에만 신경 쓰도록 합시다.
21 . 1문(Statement)
변수는 변수 선언을 통해 생성되며, 이는 이제 컴파일러에 문(statement) 지원을 추가할 때임을 의미합니다. 기억하시겠지만, Lox는 문을 두 가지 범주로 나눕니다. "선언(Declarations)"은 새로운 이름을 값에 바인딩하는 문입니다. 제어 흐름, print 등 다른 종류의 문은 그저 "문"이라고 불립니다. 우리는 제어 흐름 문 내부에 선언을 직접 허용하지 않습니다. 예를 들어 다음과 같이 말이죠:
if (monday) var croissant = "yes"; // Error.
이를 허용하면 변수의 스코프(scope)에 대한 혼란스러운 질문이 발생할 수 있습니다. 따라서 다른 언어와 마찬가지로, 우리는 제어 흐름 본문 내부에 허용되는 문의 하위 집합에 대해 별도의 문법 규칙을 두어 이를 문법적으로 금지합니다.
statement → exprStmt | forStmt | ifStmt | printStmt | returnStmt | whileStmt | block ;
그다음 스크립트의 최상위 레벨과 블록 내부에서 사용할 별도의 규칙을 사용합니다.
declaration → classDecl | funDecl | varDecl | statement ;
declaration 규칙에는 이름을 선언하는 문이 포함되어 있으며, 모든 문 유형이 허용되도록 statement도 포함합니다. block 자체가 statement에 포함되어 있으므로, 블록 안에 선언을 중첩하여 제어 흐름 구문 안에 선언을 넣을 수 있습니다.
이번 장에서는 몇 가지 문과 하나의 선언만 다룰 것입니다.
statement → exprStmt | printStmt ; declaration → varDecl | statement ;
지금까지 우리 VM은 파싱하고 컴파일할 수 있는 유일한 것이 단일 표현식이었으므로 "프로그램"을 단일 표현식으로 간주했습니다. 완전한 Lox 구현에서 프로그램은 일련의 선언입니다. 이제 이를 지원할 준비가 되었습니다.
advance();
in compile()
replace 2 lines
while (!match(TOKEN_EOF)) { declaration(); // 선언을 컴파일합니다. }
endCompiler();
소스 파일의 끝에 도달할 때까지 선언을 계속 컴파일합니다. 단일 선언은 다음을 사용하여 컴파일합니다.
add after expression()
static void declaration() { statement(); }
변수 선언은 장의 뒷부분에서 다룰 것이므로, 지금은 단순히 statement()로 전달합니다.
add after declaration()
static void statement() { if (match(TOKEN_PRINT)) { printStatement(); } }
블록은 선언을 포함할 수 있고, 제어 흐름 문은 다른 문을 포함할 수 있습니다. 이는 이 두 함수가 결국 재귀적이 될 것임을 의미합니다. 지금 미리 전방 선언을 해두는 것이 좋습니다.
static void expression();
add after expression()
static void statement(); static void declaration();
static ParseRule* getRule(TokenType type);
21 . 1 . 1Print 문
이번 장에서 지원해야 할 두 가지 문 유형이 있습니다. 당연히 print 토큰으로 시작하는 print 문부터 시작해봅시다. 다음 헬퍼 함수를 사용하여 이를 감지합니다:
add after consume()
static bool match(TokenType type) { if (!check(type)) return false; advance(); return true; }
jlox에서 보셨을 수도 있습니다. 현재 토큰이 주어진 유형과 일치하면, 해당 토큰을 소모하고 true를 반환합니다. 그렇지 않으면 토큰을 그대로 두고 false를 반환합니다. 이 헬퍼 함수는 다음 다른 헬퍼를 사용하여 구현됩니다:
add after consume()
static bool check(TokenType type) { return parser.current.type == type; }
check() 함수는 현재 토큰이 주어진 유형과 일치하면 true를 반환합니다. 이를 함수로 감싸는 것이 다소 우스꽝스럽게 보일 수도 있지만, 나중에 더 많이 사용하게 될 것이며, 이처럼 동사형 이름의 짧은 함수들은 파서(parser)를 읽기 쉽게 만든다고 생각합니다.
만약 print 토큰과 일치했다면, 여기서 나머지 문을 컴파일합니다:
add after expression()
static void printStatement() { expression(); consume(TOKEN_SEMICOLON, "Expect ';' after value."); emitByte(OP_PRINT); }
print 문은 표현식을 평가하고 그 결과를 출력하므로, 먼저 해당 표현식을 파싱하고 컴파일합니다. 문법은 그 뒤에 세미콜론을 예상하므로 이를 소모합니다. 마지막으로, 결과를 출력하기 위한 새로운 명령어를 내보냅니다.
OP_NEGATE,
in enum OpCode
OP_PRINT,
OP_RETURN,
런타임 시에는 이 명령어를 다음과 같이 실행합니다:
break;
in run()
case OP_PRINT: { printValue(pop()); printf("\n"); break; }
case OP_RETURN: {
인터프리터가 이 명령어에 도달하면, 이미 표현식 코드를 실행하여 스택 맨 위에 결과 값을 남겨둔 상태입니다. 이제 단순히 그 값을 팝(pop)하고 출력합니다.
그 이후에 다른 것을 푸시(push)하지 않는다는 점에 유의하세요. 이것이 VM에서 표현식과 문의 주요 차이점입니다. 모든 바이트코드 명령어는 스택을 어떻게 변경하는지 설명하는 스택 효과(stack effect)를 가집니다. 예를 들어, OP_ADD는 두 개의 값을 팝하고 하나의 값을 푸시하여 스택을 이전보다 한 요소 작게 만듭니다.
일련의 명령어들의 스택 효과를 합산하여 총 효과를 얻을 수 있습니다. 완전한 표현식에서 컴파일된 일련의 명령어들의 스택 효과를 더하면 총합이 1이 됩니다. 각 표현식은 스택에 하나의 결과 값을 남깁니다.
전체 문(statement)에 대한 바이트코드는 총 스택 효과가 0입니다. 문은 값을 생성하지 않으므로, 작업 중에 스택을 사용하더라도 궁극적으로 스택을 변경하지 않습니다. 이는 우리가 제어 흐름과 반복문을 다룰 때 프로그램이 긴 일련의 문들을 실행할 수 있기 때문에 중요합니다. 각 문이 스택을 늘리거나 줄이면, 결국 오버플로(overflow)나 언더플로(underflow)가 발생할 수 있습니다.
인터프리터 루프 내에 있는 동안, 약간의 코드를 삭제해야 합니다.
case OP_RETURN: {
in run()
replace 2 lines
// 인터프리터 종료.
return INTERPRET_OK;
VM이 단일 표현식만 컴파일하고 평가했을 때는, OP_RETURN에 값을 출력하기 위한 임시 코드가 있었습니다. 이제 문과 print를 지원하므로 더 이상 필요 없습니다. clox의 완전한 구현에 한 걸음 더 다가섰습니다.
늘 그렇듯이, 새로운 명령어는 디스어셈블러(disassembler) 지원이 필요합니다.
return simpleInstruction("OP_NEGATE", offset);
in disassembleInstruction()
case OP_PRINT: return simpleInstruction("OP_PRINT", offset);
case OP_RETURN:
이것이 우리의 print 문입니다. 원한다면 한번 실행해보세요:
print 1 + 2; print 3 * 4;
흥미롭죠! 음, 어쩌면 스릴 넘치지는 않을 수도 있지만, 이제 원하는 만큼 많은 문을 포함하는 스크립트를 만들 수 있게 되었으니, 진전이 있다고 느껴집니다.
21 . 1 . 2표현식 문
다음 문을 보실 때까지 기다려 주세요. print 키워드를 보지 못했다면, 우리는 표현식 문을 보고 있는 것이 틀림없습니다.
printStatement();
in statement()
} else { expressionStatement();
}
파싱은 다음과 같이 이루어집니다:
add after expression()
static void expressionStatement() { expression(); consume(TOKEN_SEMICOLON, "Expect ';' after expression."); emitByte(OP_POP); }
“표현식 문(expression statement)”은 단순히 세미콜론으로 끝나는 표현식입니다. 이는 문이 예상되는 컨텍스트에서 표현식을 작성하는 방법입니다. 일반적으로 이는 함수를 호출하거나 부수 효과(side effect)를 위해 할당을 평가할 때 사용됩니다. 예를 들어 다음과 같이 말이죠:
brunch = "quiche"; eat(brunch);
의미적으로, 표현식 문은 표현식을 평가하고 그 결과를 버립니다. 컴파일러는 이 동작을 직접 인코딩합니다. 표현식을 컴파일한 다음, OP_POP 명령어를 내보냅니다.
OP_FALSE,
in enum OpCode
OP_POP,
OP_EQUAL,
이름에서 알 수 있듯이, 이 명령어는 스택 맨 위의 값을 팝하고 잊어버립니다.
case OP_FALSE: push(BOOL_VAL(false)); break;
in run()
case OP_POP: pop(); break;
case OP_EQUAL: {
또한 디스어셈블할 수도 있습니다.
return simpleInstruction("OP_FALSE", offset);
in disassembleInstruction()
case OP_POP: return simpleInstruction("OP_POP", offset);
case OP_EQUAL:
현재로서는 부수 효과가 있는 표현식을 만들 수 없으므로 표현식 문은 그다지 유용하지 않지만, 나중에 함수를 추가할 때 필수적이 될 것입니다. C와 같은 언어의 실제 코드에서 대부분의 문은 표현식 문입니다.
21 . 1 . 3오류 동기화
컴파일러에서 이 초기 작업을 진행하는 동안, 몇 장 전에 남겨두었던 느슨한 끝을 묶을 수 있습니다. jlox와 마찬가지로 clox는 패닉 모드 오류 복구(panic mode error recovery)를 사용하여 보고되는 연쇄적인 컴파일 오류 수를 최소화합니다. 컴파일러는 동기화 지점(synchronization point)에 도달하면 패닉 모드를 종료합니다. Lox의 경우, 우리는 문(statement) 경계를 그 지점으로 선택했습니다. 이제 문을 갖췄으니 동기화를 구현할 수 있습니다.
statement();
in declaration()
replace 1 line
if (parser.panicMode) synchronize();
}
이전 문을 파싱하는 동안 컴파일 오류가 발생하면, 패닉 모드에 진입합니다. 이런 일이 발생하면, 문 이후에 동기화를 시작합니다.
add after printStatement()
static void synchronize() { parser.panicMode = false; while (parser.current.type != TOKEN_EOF) { if (parser.previous.type == TOKEN_SEMICOLON) return; switch (parser.current.type) { case TOKEN_CLASS: case TOKEN_FUN: case TOKEN_VAR: case TOKEN_FOR: case TOKEN_IF: case TOKEN_WHILE: case TOKEN_PRINT: case TOKEN_RETURN: return; default: ; // 아무것도 하지 않습니다. } advance(); } }
문의 경계처럼 보이는 것에 도달할 때까지 토큰을 무작위로 건너뜁니다. 세미콜론과 같이 문을 끝낼 수 있는 이전 토큰을 찾거나, 제어 흐름 또는 선언 키워드 중 하나와 같이 문을 시작하는 다음 토큰을 찾아 경계를 인식합니다.
21 . 2변수 선언
단순히 출력만 할 수 있는 것으로는 프로그래밍 언어 박람회에서 어떤 상도 받을 수 없으니, 좀 더 야심찬 다음 단계로 넘어가 변수를 사용해봅시다. 우리가 지원해야 할 세 가지 작업이 있습니다:
var문을 사용하여 새 변수를 선언합니다.- 식별자 표현식을 사용하여 변수의 값에 접근합니다.
- 할당 표현식을 사용하여 기존 변수에 새 값을 저장합니다.
변수가 없으면 마지막 두 가지 작업 중 어느 것도 할 수 없으므로, 선언부터 시작합니다.
static void declaration() {
in declaration()
replace 1 line
if (match(TOKEN_VAR)) { varDeclaration(); } else { statement(); }
if (parser.panicMode) synchronize();
선언 문법 규칙을 위해 대략적으로 스케치했던 자리표시자(placeholder) 파싱 함수에 이제 실제 프로덕션(production)이 생겼습니다. var 토큰을 만나면 이리로 이동합니다:
add after expression()
static void varDeclaration() { uint8_t global = parseVariable("Expect variable name."); if (match(TOKEN_EQUAL)) { expression(); } else { emitByte(OP_NIL); } consume(TOKEN_SEMICOLON, "Expect ';' after variable declaration."); defineVariable(global); }
키워드 다음에는 변수 이름이 옵니다. 이는 잠시 후에 살펴볼 parseVariable()에 의해 컴파일됩니다. 그런 다음 `=` 뒤에 초기화 표현식이 오는지를 찾습니다. 사용자가 변수를 초기화하지 않으면, 컴파일러는 OP_NIL 명령어를 내보내어 암묵적으로 nil로 초기화합니다. 어떤 경우든, 문은 세미콜론으로 끝나야 한다고 예상합니다.
여기에는 변수 및 식별자 작업과 관련된 두 가지 새로운 함수가 있습니다. 첫 번째는 다음과 같습니다:
static void parsePrecedence(Precedence precedence);
add after parsePrecedence()
static uint8_t parseVariable(const char* errorMessage) { consume(TOKEN_IDENTIFIER, errorMessage); return identifierConstant(&parser.previous); }
이 함수는 다음 토큰이 식별자여야 한다고 요구하며, 이를 소모하고 다음으로 보냅니다:
static void parsePrecedence(Precedence precedence);
add after parsePrecedence()
static uint8_t identifierConstant(Token* name) { return makeConstant(OBJ_VAL(copyString(name->start, name->length))); }
이 함수는 주어진 토큰을 가져와 그 렉심(lexeme)을 청크(chunk)의 상수 테이블에 문자열로 추가합니다. 그리고 그 상수의 상수 테이블 내 인덱스를 반환합니다.
전역 변수는 런타임에 이름으로 조회됩니다. 이는 VM, 즉 바이트코드 인터프리터 루프가 이름에 접근해야 한다는 것을 의미합니다. 전체 문자열은 피연산자(operand)로 바이트코드 스트림에 넣기에는 너무 큽니다. 대신, 문자열을 상수 테이블에 저장하고, 명령어는 테이블 내 해당 인덱스로 이름을 참조합니다.
이 함수는 해당 인덱스를 varDeclaration()으로 반환하며, varDeclaration()은 나중에 이를 다음 함수로 넘깁니다:
add after parseVariable()
static void defineVariable(uint8_t global) { emitBytes(OP_DEFINE_GLOBAL, global); }
이 함수는 새 변수를 정의하고 초기 값을 저장하는 바이트코드 명령어를 출력합니다. 상수 테이블에서 변수 이름의 인덱스는 명령어의 피연산자가 됩니다. 스택 기반 VM에서 흔히 그렇듯이, 이 명령어를 가장 마지막에 내보냅니다. 런타임 시에는 변수의 초기화 코드를 먼저 실행하여 스택에 값을 남깁니다. 그런 다음 이 명령어가 그 값을 가져와 나중에 사용할 수 있도록 저장합니다.
런타임 부분에서는 이 새로운 명령어로 시작합니다:
OP_POP,
in enum OpCode
OP_DEFINE_GLOBAL,
OP_EQUAL,
우리의 편리한 해시 테이블 덕분에 구현은 그리 어렵지 않습니다.
case OP_POP: pop(); break;
in run()
case OP_DEFINE_GLOBAL: { ObjString* name = READ_STRING(); tableSet(&vm.globals, name, peek(0)); pop(); break; }
case OP_EQUAL: {
상수 테이블에서 변수 이름을 가져옵니다. 그런 다음 스택 맨 위에서 값을 가져와서 해당 이름을 키로 사용하여 해시 테이블에 저장합니다.
이 코드는 키가 테이블에 이미 있는지 확인하지 않습니다. Lox는 전역 변수에 대해 상당히 관대하며, 오류 없이 재정의할 수 있도록 허용합니다. 이는 REPL 세션에서 유용하며, 따라서 VM은 키가 해시 테이블에 이미 있는 경우 단순히 값을 덮어씀으로써 이를 지원합니다.
또 다른 작은 헬퍼 매크로가 있습니다:
#define READ_CONSTANT() (vm.chunk->constants.values[READ_BYTE()])
in run()
#define READ_STRING() AS_STRING(READ_CONSTANT())
#define BINARY_OP(valueType, op) \
이는 바이트코드 청크에서 한 바이트 피연산자를 읽습니다. 이를 청크의 상수 테이블 인덱스로 취급하고 해당 인덱스의 문자열을 반환합니다. 이 값이 문자열인지는 확인하지 않고, 그냥 무차별적으로 캐스팅합니다. 이는 컴파일러가 문자열이 아닌 상수를 참조하는 명령어를 절대 내보내지 않기 때문에 안전합니다.
어휘적 청결성을 중요하게 생각하기 때문에, 인터프리트 함수 끝에서 이 매크로를 정의 해제합니다.
#undef READ_CONSTANT
in run()
#undef READ_STRING
#undef BINARY_OP
계속 "해시 테이블"이라고 말하지만, 실제로는 아직 해시 테이블이 없습니다. 이 전역 변수들을 저장할 장소가 필요합니다. clox가 실행되는 동안 전역 변수들이 유지되기를 원하므로, VM 안에 직접 저장합니다.
Value* stackTop;
in struct VM
Table globals;
Table strings;
문자열 테이블과 마찬가지로, VM이 부팅될 때 해시 테이블을 유효한 상태로 초기화해야 합니다.
vm.objects = NULL;
in initVM()
initTable(&vm.globals);
initTable(&vm.strings);
그리고 종료할 때 이를 해제합니다.
void freeVM() {
in freeVM()
freeTable(&vm.globals);
freeTable(&vm.strings);
늘 그렇듯이, 새로운 명령어 또한 디스어셈블할 수 있도록 하고 싶습니다.
return simpleInstruction("OP_POP", offset);
in disassembleInstruction()
case OP_DEFINE_GLOBAL: return constantInstruction("OP_DEFINE_GLOBAL", chunk, offset);
case OP_EQUAL:
이제 전역 변수를 정의할 수 있게 되었습니다. 사용자들이 정의했다는 것을 알아차릴 수는 없을 것입니다. 왜냐하면 실제로 사용할 수 없기 때문입니다. 그러니 다음으로 이 문제를 해결해봅시다.
21 . 3변수 읽기
모든 프로그래밍 언어가 그렇듯이, 우리는 변수의 이름을 사용하여 변수의 값에 접근합니다. 여기서 식별자 토큰을 표현식 파서에 연결합니다:
[TOKEN_LESS_EQUAL] = {NULL, binary, PREC_COMPARISON},
replace 1 line
[TOKEN_IDENTIFIER] = {variable, NULL, PREC_NONE},
[TOKEN_STRING] = {string, NULL, PREC_NONE},
이는 다음 새 파서 함수를 호출합니다:
add after string()
static void variable() { namedVariable(parser.previous); }
선언과 마찬가지로, 지금은 무의미해 보이는 몇몇 작은 헬퍼 함수들이 있지만, 나중에 더 많은 장에서 유용해질 것입니다. 약속합니다.
add after string()
static void namedVariable(Token name) { uint8_t arg = identifierConstant(&name); emitBytes(OP_GET_GLOBAL, arg); }
이 함수는 이전에 사용했던 identifierConstant() 함수를 호출하여 주어진 식별자 토큰을 가져와 그 렉심을 청크의 상수 테이블에 문자열로 추가합니다. 남은 것은 해당 이름의 전역 변수를 로드하는 명령어를 내보내는 것뿐입니다. 다음은 그 명령어입니다:
OP_POP,
in enum OpCode
OP_GET_GLOBAL,
OP_DEFINE_GLOBAL,
인터프리터에서는 구현이 OP_DEFINE_GLOBAL과 유사합니다.
case OP_POP: pop(); break;
in run()
case OP_GET_GLOBAL: { ObjString* name = READ_STRING(); Value value; if (!tableGet(&vm.globals, name, &value)) { runtimeError("Undefined variable '%s'.", name->chars); return INTERPRET_RUNTIME_ERROR; } push(value); break; }
case OP_DEFINE_GLOBAL: {
명령어의 피연산자에서 상수 테이블 인덱스를 가져와 변수 이름을 얻습니다. 그런 다음 이를 키로 사용하여 전역 변수 해시 테이블에서 변수 값을 조회합니다.
만약 해시 테이블에 키가 존재하지 않는다면, 해당 전역 변수가 전혀 정의된 적이 없다는 의미입니다. 이는 Lox에서 런타임 오류이므로, 이런 일이 발생하면 오류를 보고하고 인터프리터 루프를 종료합니다. 그렇지 않으면 값을 가져와 스택에 푸시합니다.
return simpleInstruction("OP_POP", offset);
in disassembleInstruction()
case OP_GET_GLOBAL: return constantInstruction("OP_GET_GLOBAL", chunk, offset);
case OP_DEFINE_GLOBAL:
약간의 디스어셈블링을 마치면 작업이 완료됩니다. 이제 우리 인터프리터는 다음과 같은 코드를 실행할 수 있습니다:
var beverage = "cafe au lait"; var breakfast = "beignets with " + beverage; print breakfast;
남은 작업은 하나뿐입니다.
21 . 4할당
이 책 전반에 걸쳐 저는 여러분이 비교적 안전하고 쉬운 길을 가도록 노력했습니다. 어려운 문제를 피하지는 않지만, 해결책이 필요 이상으로 복잡해지지 않도록 애썼습니다. 하지만 안타깝게도, 우리 바이트코드 컴파일러의 다른 설계 선택으로 인해 할당을 구현하는 것이 번거로워졌습니다.
우리 바이트코드 VM은 단일 패스(single-pass) 컴파일러를 사용합니다. 중간 AST(추상 구문 트리) 없이 즉석에서 바이트코드를 파싱하고 생성합니다. 구문 조각을 인식하는 즉시 해당 코드에 대한 바이트코드를 내보냅니다. 할당은 이런 방식에 자연스럽게 맞지 않습니다. 다음을 고려해 보세요:
menu.brunch(sunday).beverage = "mimosa";
이 코드에서 파서는 첫 번째 menu 이후 여러 토큰이 지나 `=`에 도달하기 전까지 menu.brunch(sunday).beverage가 할당의 대상이지 일반적인 표현식이 아니라는 것을 인식하지 못합니다. 그때쯤이면 컴파일러는 이미 전체에 대한 바이트코드를 내보낸 상태입니다.
하지만 문제는 보이는 것만큼 심각하지 않습니다. 파서가 해당 예시를 어떻게 보는지 살펴보세요:
.beverage 부분이 get 표현식으로 컴파일되어서는 안 되지만, .의 왼쪽에 있는 모든 것은 일반적인 표현식 의미를 가지는 표현식입니다. menu.brunch(sunday) 부분은 평소처럼 컴파일되고 실행될 수 있습니다.
다행히도, 할당 연산자 왼쪽에 있는 유일한 의미론적 차이는 토큰의 가장 오른쪽에, 즉 `=` 바로 앞에 나타납니다. 세터(setter)의 수신자가 임의로 긴 표현식일 수 있지만, get 표현식과 동작이 다른 부분은 `=` 바로 앞에 오는 마지막 식별자뿐입니다. beverage가 getter가 아닌 set 표현식으로 컴파일되어야 한다는 것을 깨닫기 위해 많은 미리보기가 필요하지 않습니다.
변수는 `=` 앞에 단일 식별자만 오므로 훨씬 더 간단합니다. 그래서 아이디어는 할당 대상으로도 사용될 수 있는 표현식을 컴파일하기 직전에, 그 뒤에 오는 `=` 토큰을 찾는 것입니다. 만약 `=`를 발견하면, 변수 접근이나 getter 대신 할당 또는 setter로 컴파일합니다.
아직 setter에 대해 걱정할 필요는 없으므로, 변수만 처리하면 됩니다.
uint8_t arg = identifierConstant(&name);
in namedVariable()
replace 1 line
if (match(TOKEN_EQUAL)) { expression(); emitBytes(OP_SET_GLOBAL, arg); } else { emitBytes(OP_GET_GLOBAL, arg); }
}
식별자 표현식의 파싱 함수에서, 식별자 뒤에 등호(=)를 찾습니다. 만약 등호를 찾으면, 변수 접근을 위한 코드를 내보내는 대신, 할당될 값을 컴파일한 다음 할당 명령어를 내보냅니다.
이것이 이번 장에서 추가해야 할 마지막 명령어입니다.
OP_DEFINE_GLOBAL,
in enum OpCode
OP_SET_GLOBAL,
OP_EQUAL,
예상하시겠지만, 런타임 동작은 새 변수를 정의하는 것과 유사합니다.
}
in run()
case OP_SET_GLOBAL: { ObjString* name = READ_STRING(); if (tableSet(&vm.globals, name, peek(0))) { tableDelete(&vm.globals, name); // 이 값이 전역 변수 테이블에 저장되었더라도, 변수가 이전에 정의되지 않았다면 삭제하여 런타임 오류가 보고된 후에도 REPL 세션에 이 좀비 값이 남지 않도록 합니다. runtimeError("Undefined variable '%s'.", name->chars); return INTERPRET_RUNTIME_ERROR; } break; }
case OP_EQUAL: {
주요 차이점은 전역 해시 테이블에 키가 아직 존재하지 않을 때 발생하는 일입니다. 변수가 아직 정의되지 않았다면, 그 변수에 할당을 시도하는 것은 런타임 오류입니다. Lox는 암묵적인 변수 선언을 하지 않습니다.
다른 차이점은 변수를 설정할 때 스택에서 값을 팝하지 않는다는 것입니다. 할당은 표현식이므로, 할당이 더 큰 표현식 내부에 중첩될 경우를 대비하여 그 값을 스택에 남겨두어야 합니다.
디스어셈블링을 추가합니다:
return constantInstruction("OP_DEFINE_GLOBAL", chunk,
offset);
in disassembleInstruction()
case OP_SET_GLOBAL: return constantInstruction("OP_SET_GLOBAL", chunk, offset);
case OP_EQUAL:
자, 이제 다 된 건가요? 글쎄요... 아직 아닙니다. 우리가 실수를 저질렀습니다! 다음 코드를 한번 살펴보세요:
a * b = c + d;
Lox의 문법에 따르면 `=`는 가장 낮은 우선순위를 가지므로, 이는 대략 다음과 같이 파싱되어야 합니다:
분명히, a * b는 유효한 할당 대상이 아니므로, 이는 구문 오류여야 합니다. 하지만 우리 파서는 다음을 수행합니다:
- 먼저,
parsePrecedence()는variable()접두사 파서(prefix parser)를 사용하여a를 파싱합니다. - 그 후, 중위 연산자 파싱 루프(infix parsing loop)에 진입합니다.
*에 도달하여binary()를 호출합니다.binary()는 재귀적으로parsePrecedence()를 호출하여 우변 피연산자를 파싱합니다.parsePrecedence()는 다시variable()을 호출하여b를 파싱합니다.variable()호출 내부에서, 뒤에 오는 `=`를 찾습니다. 이를 발견하고 나머지 줄을 할당으로 파싱합니다.
다른 말로 하면, 파서는 위 코드를 다음과 같이 봅니다:
variable()이 변수를 포함하는 주변 표현식의 우선순위를 고려하지 않았기 때문에 우선순위 처리를 망쳤습니다. 만약 변수가 중위 연산자의 우변이거나 단항 연산자의 피연산자라면, 해당 포함 표현식은 `=`를 허용하기에는 너무 높은 우선순위를 가집니다.
이것을 고치려면, variable()은 낮은 우선순위 표현식의 컨텍스트에 있을 때만 `=`를 찾아 소모해야 합니다. 현재 우선순위를 아는 코드는 논리적으로 parsePrecedence()입니다. variable() 함수는 실제 우선순위 레벨을 알 필요는 없습니다. 그저 할당을 허용할 만큼 우선순위가 충분히 낮은지에만 신경 쓰므로, 이 사실을 불리언(Boolean) 값으로 전달합니다.
error("Expect expression.");
return;
}
in parsePrecedence()
replace 1 line
bool canAssign = precedence <= PREC_ASSIGNMENT; prefixRule(canAssign);
while (precedence <= getRule(parser.current.type)->precedence) {
할당은 가장 낮은 우선순위 표현식이므로, 할당을 허용하는 유일한 경우는 할당 표현식이나 표현식 문과 같은 최상위 레벨 표현식을 파싱할 때입니다. 이 플래그는 여기 파서 함수로 전달됩니다:
function variable()
replace 3 lines
static void variable(bool canAssign) { namedVariable(parser.previous, canAssign); }
이는 새로운 매개변수를 통해 전달됩니다:
function namedVariable()
replace 1 line
static void namedVariable(Token name, bool canAssign) {
uint8_t arg = identifierConstant(&name);
그리고 마지막으로 여기서 사용됩니다:
uint8_t arg = identifierConstant(&name);
in namedVariable()
replace 1 line
if (canAssign && match(TOKEN_EQUAL)) {
expression();
컴파일러의 올바른 위치에 단 하나의 비트 데이터를 전달하기 위한 많은 파이프라인 작업이지만, 어쨌든 도착했습니다. 변수가 더 높은 우선순위의 표현식 내부에 중첩되어 있다면, canAssign은 false가 될 것이고, `=`가 있더라도 이를 무시할 것입니다. 그런 다음 namedVariable()이 반환되고, 실행은 결국 parsePrecedence()로 돌아갑니다.
그 다음은요? 컴파일러는 이전에 오류가 발생했던 예제 코드를 어떻게 처리할까요? 지금은 variable()이 `=`를 소모하지 않으므로, 그 토큰이 현재 토큰이 될 것입니다. 컴파일러는 variable() 접두사 파서에서 parsePrecedence()로 돌아간 다음 중위 파싱 루프에 진입하려고 시도합니다. `=`와 관련된 파싱 함수가 없으므로 해당 루프를 건너뜁니다.
그리고 parsePrecedence()는 조용히 호출자로 돌아갑니다. 이것도 올바르지 않습니다. 만약 `=`가 표현식의 일부로 소모되지 않는다면, 다른 어떤 것도 이를 소모하지 않을 것입니다. 이는 오류이므로 보고해야 합니다.
infixRule(); }
in parsePrecedence()
if (canAssign && match(TOKEN_EQUAL)) { error("Invalid assignment target."); }
}
이로써, 이전의 잘못된 프로그램은 컴파일 타임에 올바르게 오류를 받습니다. 좋아요, 이제 끝난 건가요? 아직 아닙니다. 보세요, 우리는 파싱 함수 중 하나에 인자를 전달하고 있습니다. 하지만 이 함수들은 함수 포인터 테이블에 저장되므로, 모든 파싱 함수는 동일한 타입을 가져야 합니다. 대부분의 파싱 함수는 할당 대상으로 사용되는 것을 지원하지 않지만 (세터(setter)가 유일한 다른 경우입니다), 우리의 친절한 C 컴파일러는 모든 함수가 이 매개변수를 받도록 요구합니다.
이제 이 장을 약간의 단순 반복 작업으로 마무리하겠습니다. 먼저, 중위 파싱 함수에 플래그를 전달해봅시다.
ParseFn infixRule = getRule(parser.previous.type)->infix;
in parsePrecedence()
replace 1 line
infixRule(canAssign);
}
결국 세터를 위해 이것이 필요할 것입니다. 그런 다음 함수 타입에 대한 typedef를 수정할 것입니다.
} Precedence;
add after enum Precedence
replace 1 line
typedef void (*ParseFn)(bool canAssign);
typedef struct {
그리고 모든 기존 파싱 함수에서 이 매개변수를 받아들이기 위한 완전히 지루한 코드를 추가해야 합니다. 여기요:
function binary()
replace 1 line
static void binary(bool canAssign) {
TokenType operatorType = parser.previous.type;
그리고 여기:
function literal()
replace 1 line
static void literal(bool canAssign) {
switch (parser.previous.type) {
그리고 여기:
function grouping()
replace 1 line
static void grouping(bool canAssign) {
expression();
그리고 여기:
function number()
replace 1 line
static void number(bool canAssign) {
double value = strtod(parser.previous.start, NULL);
그리고 여기도:
function string()
replace 1 line
static void string(bool canAssign) {
emitConstant(OBJ_VAL(copyString(parser.previous.start + 1,
그리고 마지막으로:
function unary()
replace 1 line
static void unary(bool canAssign) {
TokenType operatorType = parser.previous.type;
휴! 다시 컴파일 가능한 C 프로그램이 되었습니다. 실행해보면 이제 다음 코드를 돌릴 수 있습니다:
var breakfast = "beignets"; var beverage = "cafe au lait"; breakfast = "beignets with " + beverage; print breakfast;
이제 실제 언어의 실제 코드처럼 보이기 시작합니다!
도전 과제
-
컴파일러는 식별자가 나타날 때마다 전역 변수 이름을 문자열로 상수 테이블에 추가합니다. 동일한 변수 이름이 상수 테이블의 이전 슬롯에 이미 있더라도 매번 새로운 상수를 생성합니다. 이는 동일한 함수에서 같은 변수를 여러 번 참조하는 경우 낭비적입니다. 이는 결과적으로 상수 테이블을 채우고 슬롯이 부족해질 가능성을 높입니다. 우리는 단일 청크에 256개의 상수만 허용하기 때문입니다.
이를 최적화하세요. 당신의 최적화가 런타임 성능에 비해 컴파일러 성능에 어떤 영향을 미칩니까? 이것이 올바른 트레이드오프(trade-off)입니까?
-
전역 변수를 사용할 때마다 해시 테이블에서 이름으로 조회하는 것은 좋은 해시 테이블을 사용하더라도 상당히 느립니다. 의미론을 변경하지 않으면서 전역 변수를 저장하고 접근하는 더 효율적인 방법을 고안할 수 있습니까?
-
REPL에서 실행할 때, 사용자가 알 수 없는 전역 변수를 참조하는 함수를 작성할 수 있습니다. 그리고 다음 줄에서 해당 변수를 선언할 수도 있습니다. Lox는 함수가 처음 정의될 때 "알 수 없는 변수" 컴파일 오류를 보고하지 않음으로써 이를 유연하게 처리해야 합니다.
하지만 사용자가 Lox 스크립트를 실행할 때, 컴파일러는 코드가 실행되기 전에 전체 프로그램 텍스트에 접근할 수 있습니다. 다음 프로그램을 고려해보세요:
fun useVar() { print oops; } var ooops = "too many o's!";
여기서 우리는
oops가 프로그램의 어느 곳에서도 전역으로 선언되지 않았기 때문에 정의되지 않을 것임을 정적으로 알 수 있습니다.useVar()가 호출되지도 않으므로, 변수가 정의되지 않았음에도 불구하고 사용되지 않기 때문에 런타임 오류는 발생하지 않습니다.적어도 스크립트에서 실행할 때, 이런 실수를 컴파일 오류로 보고할 수 있습니다. 그렇게 해야 한다고 생각하십니까? 답변을 정당화하세요. 당신이 아는 다른 스크립팅 언어들은 어떻게 합니까?