값의 종류
당신이 아주 작은 두뇌를 가진 곰이고 무언가를 생각할 때, 때로는 당신 안에 있던 어떤 것이 막상 세상 밖으로 나와 다른 사람들의 눈에 띄면 완전히 다르게 보인다는 것을 발견하게 될 것입니다.
A. A. Milne, 곰돌이 푸
지난 몇 장은 방대한 내용과 복잡한 기술, 그리고 수많은 코드들로 가득 차 있었습니다. 이번 장에서는 새로운 개념 하나와 간단한 코드 몇 줄만 배우면 되니 잠시 쉬어가세요.
Lox는 동적 타입 언어입니다. 하나의 변수가 시간의 흐름에 따라 불리언, 숫자, 문자열을 담을 수 있죠. 적어도 개념은 그렇습니다. 현재 clox에서는 모든 값이 숫자입니다. 이번 장이 끝날 때쯤에는 불리언과 nil도 지원하게 될 것입니다. 이 값들이 특별히 흥미롭지는 않지만, 이들을 통해 값 표현 방식이 어떻게 다양한 타입을 동적으로 처리할 수 있는지 알아야 합니다.
18 . 1태그가 지정된 유니온
C 언어에서 작업하는 것의 장점은 우리가 데이터 구조를 가장 기본적인 비트부터 직접 만들 수 있다는 것입니다. 단점은 반드시 그렇게 해야 한다는 것입니다. C는 컴파일 시점에 많은 것을 공짜로 제공하지 않으며, 런타임에는 훨씬 더 적게 제공합니다. C 언어의 관점에서 보면, 세상은 구별되지 않는 바이트 배열입니다. 얼마나 많은 바이트를 사용할지, 그리고 그 바이트들이 무엇을 의미하는지 결정하는 것은 전적으로 우리에게 달려있습니다.
값 표현 방식을 선택하려면 두 가지 핵심 질문에 답해야 합니다.
-
값의 타입을 어떻게 표현할 것인가? 예를 들어, 숫자에
true를 곱하려고 한다면, 런타임에 그 오류를 감지하고 보고해야 합니다. 그러기 위해서는 값의 타입을 알 수 있어야 합니다. -
값 자체를 어떻게 저장할 것인가? 3이 숫자라는 것뿐만 아니라, 4라는 숫자와 다르다는 것을 구별할 수 있어야 합니다. 당연한 것처럼 들리죠? 하지만 우리는 이러한 것들을 명확히 설명해야 하는 수준에서 작업하고 있습니다.
이 언어를 설계할 뿐만 아니라 직접 구축하는 입장에서, 이 두 가지 질문에 답할 때 구현자의 영원한 숙제인 '효율적으로 처리하는 것' 또한 명심해야 합니다.
수년간 언어 해커들은 위 정보를 가능한 적은 비트에 담기 위해 다양한 기발한 방법을 고안해 왔습니다. 지금은 가장 간단하고 고전적인 해결책인 태그가 지정된 유니온(tagged union)으로 시작할 것입니다. 값은 타입 "태그"와 실제 값을 위한 페이로드(payload)의 두 부분으로 구성됩니다. 값의 타입을 저장하기 위해 VM이 지원하는 각 값 종류에 대한 열거형을 정의합니다.
#include "common.h"
typedef enum { VAL_BOOL, VAL_NIL, VAL_NUMBER, } ValueType;
typedef double Value;
지금은 몇 가지 케이스만 있지만, clox에 문자열, 함수, 클래스를 추가함에 따라 이 열거형은 커질 것입니다. 타입 외에도 값의 데이터를 저장해야 합니다. 숫자의 double 값, 불리언의 true 또는 false 같은 것들이죠. 각 가능한 타입에 대한 필드를 가진 구조체를 정의할 수도 있습니다.
하지만 이것은 메모리 낭비입니다. 값은 동시에 숫자이면서 불리언일 수 없습니다. 따라서 어느 시점에서든 하나의 필드만 사용될 것입니다. C는 유니온(union)을 정의하여 이를 최적화할 수 있도록 합니다. 유니온은 모든 필드가 메모리에서 겹친다는 점을 제외하고는 구조체와 유사하게 생겼습니다.
유니온의 크기는 가장 큰 필드의 크기입니다. 모든 필드가 동일한 비트를 재사용하기 때문에, 이를 다룰 때는 매우 주의해야 합니다. 한 필드를 사용하여 데이터를 저장한 다음, 다른 필드를 사용하여 접근하면, 기본 비트의 의미를 재해석하게 됩니다.
"태그가 지정된 유니온"이라는 이름이 암시하듯이, 우리의 새로운 값 표현 방식은 이 두 부분을 단일 구조체로 결합합니다.
} ValueType;
add after enum ValueType
replace 1 line
typedef struct { ValueType type; union { bool boolean; double number; } as; } Value;
typedef struct {
타입 태그를 위한 필드가 있고, 그 다음으로 모든 기본 값들의 유니온을 포함하는 두 번째 필드가 있습니다. 일반적인 C 컴파일러를 사용하는 64비트 머신에서 레이아웃은 다음과 같습니다.
4바이트 타입 태그가 먼저 오고, 그 다음 유니온이 옵니다. 대부분의 아키텍처는 값이 자신의 크기에 맞춰 정렬되기를 선호합니다. 유니온 필드가 8바이트 double을 포함하므로, 컴파일러는 double을 가장 가까운 8바이트 경계에 유지하기 위해 타입 필드 뒤에 4바이트 패딩(padding)을 추가합니다. 이는 우리가 타입 태그에 사실상 8바이트를 사용하고 있다는 의미입니다. 타입 태그는 0에서 3 사이의 숫자만 표현하면 되는데 말이죠. 열거형을 더 작은 크기에 담을 수도 있었겠지만, 그렇게 하면 패딩만 늘어날 뿐입니다.
따라서 우리의 Value는 16바이트로, 약간 커 보입니다. 이 문제는 나중에 개선할 것입니다. 그 전까지는 C 스택에 저장하고 값으로 전달하기에 여전히 충분히 작습니다. Lox의 시맨틱스는 우리가 지금까지 지원하는 타입이 모두 불변(immutable)이기 때문에 이를 허용합니다. 숫자 3을 포함하는 Value의 복사본을 어떤 함수에 전달하더라도, 호출자가 값의 변경 사항을 보게 될까 걱정할 필요가 없습니다. 3은 "수정"할 수 없습니다. 3은 영원히 3입니다.
18 . 2Lox 값과 C 값
이것이 우리의 새로운 값 표현 방식이지만, 아직 끝나지 않았습니다. 지금 clox의 나머지 부분은 Value가 double의 별칭이라고 가정하고 있습니다. 한 타입에서 다른 타입으로 직접 C 캐스트를 수행하는 코드가 있습니다. 그 코드는 이제 모두 깨졌습니다. 안타깝네요.
새로운 표현 방식에서는 Value가 double을 포함할 수 있지만, double과 동일하지는 않습니다. 한 타입에서 다른 타입으로 가려면 필수적인 변환 단계가 필요합니다. clox를 다시 작동시키려면 코드를 훑어보면서 그 경계를 넘나드는 데이터가 있는 모든 곳에 변환을 삽입해야 합니다.
이러한 변환은 각 타입과 연산에 대한 몇 개의 매크로로 구현할 것입니다. 먼저, 네이티브 C 값을 clox Value로 승격시키는 매크로입니다.
} Value;
add after struct Value
#define BOOL_VAL(value) ((Value){VAL_BOOL, {.boolean = value}}) #define NIL_VAL ((Value){VAL_NIL, {.number = 0}}) #define NUMBER_VAL(value) ((Value){VAL_NUMBER, {.number = value}})
typedef struct {
이 매크로들은 각각 적절한 타입의 C 값을 받아 올바른 타입 태그를 가지고 기본 값을 포함하는 Value를 생성합니다. 이는 정적 타입 값을 clox의 동적 타입 세계로 끌어올립니다. 하지만 Value로 무언가를 하려면, 그 값을 풀어 C 값을 다시 얻어야 합니다.
} Value;
add after struct Value
#define AS_BOOL(value) ((value).as.boolean) #define AS_NUMBER(value) ((value).as.number)
#define BOOL_VAL(value) ((Value){VAL_BOOL, {.boolean = value}})
이 매크로들은 반대 방향으로 작동합니다. 올바른 타입의 Value를 받아서 그 값을 풀어 해당하는 원시 C 값을 반환합니다. "올바른 타입" 부분이 중요합니다! 이 매크로들은 유니온 필드에 직접 접근합니다. 만약 다음과 같이 한다면:
Value value = BOOL_VAL(true); double number = AS_NUMBER(value);
그러면 우리는 어둠의 영역(Shadow Realm)으로 가는 불타는 문을 열 수 있습니다. Value가 적절한 타입을 포함하고 있다는 것을 알기 전에는 어떤 AS_ 매크로도 사용하는 것이 안전하지 않습니다. 이를 위해, Value의 타입을 확인하는 마지막 매크로 몇 개를 정의합니다.
} Value;
add after struct Value
#define IS_BOOL(value) ((value).type == VAL_BOOL) #define IS_NIL(value) ((value).type == VAL_NIL) #define IS_NUMBER(value) ((value).type == VAL_NUMBER)
#define AS_BOOL(value) ((value).as.boolean)
이 매크로들은 Value가 해당 타입을 가지면 true를 반환합니다. AS_ 매크로 중 하나를 호출할 때마다, 먼저 이들 중 하나를 호출하여 값을 보호해야 합니다. 이 여덟 가지 매크로를 통해, Lox의 동적 세계와 C의 정적 세계 사이에서 데이터를 안전하게 주고받을 수 있습니다.
18 . 3동적 타입 숫자
값 표현 방식과 그 변환 도구를 갖추었습니다. clox를 다시 실행시키기 위해 남은 일은 코드 전체를 샅샅이 뒤져 경계를 넘나드는 데이터가 있는 모든 곳을 수정하는 것입니다. 이 책의 이런 부분들은 그리 놀랍지 않을 수도 있지만, 저는 여러분에게 모든 코드 라인을 보여주겠다고 약속했으니 여기 있습니다.
우리가 생성하는 첫 번째 값은 숫자 리터럴을 컴파일할 때 생성되는 상수입니다. 렉심(lexeme)을 C double로 변환한 후, 단순히 Value로 래핑하여 상수 테이블에 저장합니다.
double value = strtod(parser.previous.start, NULL);
in number()
replace 1 line
emitConstant(NUMBER_VAL(value));
}
런타임 쪽에는 값을 출력하는 함수가 있습니다.
void printValue(Value value) {
in printValue()
replace 1 line
printf("%g", AS_NUMBER(value));
}
Value를 printf()에 보내기 직전에, 우리는 그것을 풀고 double 값을 추출합니다. 다른 타입들을 추가하기 위해 이 함수를 곧 다시 방문할 것이지만, 먼저 기존 코드를 작동시키겠습니다.
18 . 3 . 1단항 부정 및 런타임 오류
다음으로 간단한 연산은 단항 부정입니다. 스택에서 값을 하나 꺼내 부정한 다음 결과를 푸시합니다. 이제 다른 타입의 값들이 생겼으므로, 피연산자가 숫자라고 가정할 수 없습니다. 사용자는 다음과 같이 할 수도 있습니다.
print -false; // 어...
우리는 이것을 우아하게 처리해야 하는데, 이는 런타임 오류를 처리할 때가 되었다는 의미입니다. 특정 타입을 요구하는 연산을 수행하기 전에, Value가 해당 타입인지 확인해야 합니다.
단항 부정의 경우, 확인은 다음과 같습니다.
case OP_DIVIDE: BINARY_OP(/); break;
in run()
replace 1 line
case OP_NEGATE: if (!IS_NUMBER(peek(0))) { runtimeError("피연산자는 숫자여야 합니다."); return INTERPRET_RUNTIME_ERROR; } push(NUMBER_VAL(-AS_NUMBER(pop()))); break;
case OP_RETURN: {
먼저, 스택 맨 위에 있는 Value가 숫자인지 확인합니다. 숫자가 아니면 런타임 오류를 보고하고 인터프리터를 중단합니다. 그렇지 않으면 계속 진행합니다. 이 유효성 검사 후에만 피연산자를 풀고, 부정한 다음, 결과를 래핑하여 다시 푸시합니다.
Value에 접근하기 위해 새로운 작은 함수를 사용합니다.
add after pop()
static Value peek(int distance) { return vm.stackTop[-1 - distance]; }
이 함수는 스택에서 Value를 반환하지만 팝하지는 않습니다. distance 인수는 스택 맨 위에서 얼마나 아래를 볼 것인지를 나타냅니다. 0은 맨 위, 1은 한 칸 아래 등입니다.
런타임 오류는 이 책의 나머지 부분에서 많이 활용할 새로운 함수를 사용하여 보고합니다.
add after resetStack()
static void runtimeError(const char* format, ...) { va_list args; va_start(args, format); vfprintf(stderr, format, args); va_end(args); fputs("\n", stderr); size_t instruction = vm.ip - vm.chunk->code - 1; int line = vm.chunk->lines[instruction]; fprintf(stderr, "[line %d] in script\n", line); resetStack(); }
C에서 가변 인자 함수(인자 개수가 가변적인 함수)를 호출해 본 적은 분명 있을 것입니다. printf()가 그중 하나죠. 하지만 직접 정의해 본 적은 없을 수도 있습니다. 이 책은 C 튜토리얼이 아니므로 여기서 간단히 넘어갈 것입니다. 기본적으로 ...와 va_list 관련 기능은 runtimeError()에 임의의 개수의 인자를 전달할 수 있도록 해줍니다. 이 함수는 이 인자들을 명시적인 va_list를 받는 printf()의 한 종류인 vfprintf()로 전달합니다.
호출자는 runtimeError()에 printf()를 직접 호출할 때와 마찬가지로 형식 문자열과 여러 인자를 전달할 수 있습니다. runtimeError()는 그 인자들을 포맷하고 출력합니다. 이번 장에서는 이 기능을 활용하지 않겠지만, 다음 장에서는 다른 데이터를 포함하는 포맷된 런타임 오류 메시지를 생성할 것입니다.
도움이 되기를 바라는 오류 메시지를 표시한 후, 오류가 발생했을 때 사용자 코드의 어느 줄이 실행 중이었는지 알려줍니다. 토큰은 컴파일러에 남겨두었기 때문에, 우리는 청크에 컴파일된 디버그 정보에서 해당 줄을 찾습니다. 만약 컴파일러가 제 역할을 제대로 했다면, 이는 바이트코드가 컴파일된 소스 코드의 줄과 일치할 것입니다.
청크의 디버그 라인 배열에서 현재 바이트코드 명령 인덱스 마이너스 1을 사용하여 조회합니다. 이는 인터프리터가 각 명령을 실행하기 전에 이미 다음 명령으로 진행했기 때문입니다. 따라서 runtimeError()를 호출하는 시점에서 실패한 명령은 바로 이전 명령입니다.
va_list와 관련된 매크로를 사용하려면 표준 헤더를 포함해야 합니다.
add to top of file
#include <stdarg.h>
#include <stdio.h>
이것으로 우리 VM은 숫자를 부정할 때 올바른 동작을 할 뿐만 아니라(고치기 전에는 그랬지만), 다른 타입에 대한 잘못된 부정 시도도 우아하게 처리할 수 있습니다(아직 다른 타입이 없지만, 그래도).
18 . 3 . 2이항 산술 연산자
이제 런타임 오류 처리 메커니즘이 마련되었으므로, 이항 연산자를 수정하는 것이 더 복잡하더라도 쉬워졌습니다. 우리는 현재 네 가지 이항 연산자(+, -, *, /)를 지원합니다. 이들 간의 유일한 차이점은 사용되는 기본 C 연산자입니다. 네 가지 연산자 간의 중복 코드를 최소화하기 위해, 공통 부분을 연산자 토큰을 매개변수로 받는 거대한 전처리기 매크로로 감쌌습니다.
몇 장 전에는 그 매크로가 지나치다고 생각했지만, 오늘 그 혜택을 받게 됩니다. 필요한 타입 검사와 변환을 한 곳에 추가할 수 있기 때문입니다.
#define READ_CONSTANT() (vm.chunk->constants.values[READ_BYTE()])
in run()
replace 6 lines
#define BINARY_OP(valueType, op) \ do { \ if (!IS_NUMBER(peek(0)) || !IS_NUMBER(peek(1))) { \ runtimeError("피연산자는 숫자여야 합니다."); \ return INTERPRET_RUNTIME_ERROR; \ } \ double b = AS_NUMBER(pop()); \ double a = AS_NUMBER(pop()); \ push(valueType(a op b)); \ } while (false)
for (;;) {
네, 이 매크로가 매우 거대하다는 것을 압니다. 일반적으로 좋은 C 관행이라고는 생각하지 않지만, 그냥 계속 진행합시다. 변경 사항은 단항 부정 때와 비슷합니다. 먼저, 두 피연산자 모두 숫자인지 확인합니다. 둘 중 하나라도 숫자가 아니면 런타임 오류를 보고하고 비상 탈출 레버를 당깁니다.
피연산자가 괜찮으면 둘 다 팝하고 원래 값을 추출합니다. 그런 다음 주어진 연산자를 적용하고, 결과를 래핑하여 다시 스택에 푸시합니다. NUMBER_VAL()을 직접 사용하여 결과를 래핑하지 않는다는 점에 유의하세요. 대신, 사용할 래퍼는 매크로 매개변수로 전달됩니다. 기존 산술 연산자의 경우 결과는 숫자이므로, NUMBER_VAL 매크로를 전달합니다.
}
in run()
replace 4 lines
case OP_ADD: BINARY_OP(NUMBER_VAL, +); break; case OP_SUBTRACT: BINARY_OP(NUMBER_VAL, -); break; case OP_MULTIPLY: BINARY_OP(NUMBER_VAL, *); break; case OP_DIVIDE: BINARY_OP(NUMBER_VAL, /); break;
case OP_NEGATE:
조만간, 왜 래핑 매크로를 인자로 만들었는지 알려드리겠습니다.
18 . 4두 가지 새로운 타입
기존의 모든 clox 코드는 다시 정상 작동합니다. 드디어 새로운 타입을 추가할 때입니다. 우리는 현재 불필요한 런타임 타입 검사를 수행하는 실행 가능한 숫자 계산기를 가지고 있습니다. 내부적으로 다른 타입을 표현할 수는 있지만, 사용자 프로그램이 그런 타입의 Value를 생성할 방법은 없습니다.
지금까지는 그랬습니다. 이제 세 가지 새로운 리터럴인 true, false, nil에 대한 컴파일러 지원을 추가하는 것으로 시작할 것입니다. 모두 매우 간단하므로, 세 가지를 한 번에 처리하겠습니다.
숫자 리터럴의 경우, 수십억 개의 가능한 숫자 값이 있다는 사실을 다루어야 했습니다. 이를 위해 리터럴 값을 청크의 상수 테이블에 저장하고, 단순히 그 상수를 로드하는 바이트코드 명령을 발행했습니다. 새 타입에 대해서도 똑같이 할 수 있습니다. 예를 들어, true를 상수 테이블에 저장하고, OP_CONSTANT를 사용하여 읽어낼 수 있습니다.
하지만 이 새로운 타입들에서 다뤄야 할 가능한 값이 문자 그대로 (헤헤) 세 개뿐이라는 점을 감안하면, 두 바이트 명령과 상수 테이블 엔트리를 낭비하는 것은 불필요하고 느립니다! 대신, 이 세 리터럴 각각을 스택에 푸시하는 전용 명령을 세 개 정의할 것입니다.
OP_CONSTANT,
in enum OpCode
OP_NIL, OP_TRUE, OP_FALSE,
OP_ADD,
우리 스캐너는 이미 true, false, nil을 키워드로 처리하므로, 곧바로 파서로 넘어갈 수 있습니다. 테이블 기반 Pratt 파서에서는 해당 키워드 토큰 타입과 연결된 행에 파서 함수를 삽입하기만 하면 됩니다. 세 슬롯 모두에 동일한 함수를 사용할 것입니다. 여기:
[TOKEN_ELSE] = {NULL, NULL, PREC_NONE},
replace 1 line
[TOKEN_FALSE] = {literal, NULL, PREC_NONE},
[TOKEN_FOR] = {NULL, NULL, PREC_NONE},
여기:
[TOKEN_THIS] = {NULL, NULL, PREC_NONE},
replace 1 line
[TOKEN_TRUE] = {literal, NULL, PREC_NONE},
[TOKEN_VAR] = {NULL, NULL, PREC_NONE},
그리고 여기:
[TOKEN_IF] = {NULL, NULL, PREC_NONE},
replace 1 line
[TOKEN_NIL] = {literal, NULL, PREC_NONE},
[TOKEN_OR] = {NULL, NULL, PREC_NONE},
파서가 false, nil, 또는 true를 접두사 위치에서 만나면, 이 새로운 파서 함수를 호출합니다.
add after binary()
static void literal() { switch (parser.previous.type) { case TOKEN_FALSE: emitByte(OP_FALSE); break; case TOKEN_NIL: emitByte(OP_NIL); break; case TOKEN_TRUE: emitByte(OP_TRUE); break; default: return; // 도달 불가능. } }
parsePrecedence()가 이미 키워드 토큰을 소비했기 때문에, 우리는 올바른 명령어를 출력하기만 하면 됩니다. 파싱한 토큰의 타입에 따라 그것을 결정합니다. 이제 우리의 프론트엔드는 불리언과 nil 리터럴을 바이트코드로 컴파일할 수 있습니다. 실행 파이프라인을 따라 내려가 인터프리터에 도달합니다.
case OP_CONSTANT: {
Value constant = READ_CONSTANT();
push(constant);
break;
}
in run()
case OP_NIL: push(NIL_VAL); break; case OP_TRUE: push(BOOL_VAL(true)); break; case OP_FALSE: push(BOOL_VAL(false)); break;
case OP_ADD: BINARY_OP(NUMBER_VAL, +); break;
이것은 아주 자명합니다. 각 명령은 적절한 값을 불러와 스택에 푸시합니다. 디스어셈블러도 잊지 말아야 합니다.
case OP_CONSTANT:
return constantInstruction("OP_CONSTANT", chunk, offset);
in disassembleInstruction()
case OP_NIL: return simpleInstruction("OP_NIL", offset); case OP_TRUE: return simpleInstruction("OP_TRUE", offset); case OP_FALSE: return simpleInstruction("OP_FALSE", offset);
case OP_ADD:
이것이 마련되면, 우리는 이 세상을 뒤흔들 프로그램을 실행할 수 있습니다:
true
단, 인터프리터가 결과를 출력하려 할 때 폭발할 것입니다. printValue()를 확장하여 새로운 타입도 처리하도록 해야 합니다.
void printValue(Value value) {
in printValue()
replace 1 line
switch (value.type) { case VAL_BOOL: printf(AS_BOOL(value) ? "true" : "false"); break; case VAL_NIL: printf("nil"); break; case VAL_NUMBER: printf("%g", AS_NUMBER(value)); break; }
}
자, 이제 새로운 타입들이 생겼습니다. 아직 그리 유용하지는 않습니다. 리터럴 외에는 할 수 있는 것이 거의 없습니다. nil이 중요하게 작용하려면 시간이 좀 걸리겠지만, 불리언을 논리 연산자에서 활용하기 시작할 수 있습니다.
18 . 4 . 1논리 NOT과 거짓성(falsiness)
그리 어렵지 않았습니다. 이제 논리 연산자 중 가장 간단한 단항 NOT, 우리의 오래된 느낌표 친구를 다뤄봅시다.
print !true; // "false"
이 새로운 연산은 새로운 명령어를 얻습니다.
OP_DIVIDE,
in enum OpCode
OP_NOT,
OP_NEGATE,
단항 부정(unary negation)을 위해 작성했던 unary() 파서 함수를 재사용하여 NOT 표현식을 컴파일할 수 있습니다. 파싱 테이블에 슬롯을 채우기만 하면 됩니다.
[TOKEN_STAR] = {NULL, binary, PREC_FACTOR},
replace 1 line
[TOKEN_BANG] = {unary, NULL, PREC_NONE},
[TOKEN_BANG_EQUAL] = {NULL, NULL, PREC_NONE},
제가 이것을 할 줄 알았기 때문에, unary() 함수는 이미 어떤 바이트코드 명령어를 출력할지 결정하는 토큰 타입에 대한 switch 문을 가지고 있습니다. 우리는 단순히 케이스를 하나 더 추가합니다.
switch (operatorType) {
in unary()
case TOKEN_BANG: emitByte(OP_NOT); break;
case TOKEN_MINUS: emitByte(OP_NEGATE); break;
default: return; // 도달 불가능.
}
프론트엔드 작업은 이것으로 끝입니다. 이제 VM으로 가서 이 명령어를 생생하게 만들어 봅시다.
case OP_DIVIDE: BINARY_OP(NUMBER_VAL, /); break;
in run()
case OP_NOT: push(BOOL_VAL(isFalsey(pop()))); break;
case OP_NEGATE:
이전 단항 연산자와 마찬가지로, 하나의 피연산자를 팝하고 연산을 수행한 다음 결과를 푸시합니다. 그리고 그곳에서 했던 것처럼 동적 타이핑에 대해 걱정해야 합니다. true의 논리적 NOT을 취하는 것은 쉽지만, 제멋대로인 프로그래머가 다음과 같이 작성하는 것을 막을 수는 없습니다:
print !nil;
단항 마이너스의 경우, 숫자 이외의 것을 부정하는 것은 오류로 만들었습니다. 하지만 Lox는 대부분의 스크립팅 언어처럼 ! 연산자와 불리언이 예상되는 다른 컨텍스트에서는 더 관대합니다. 다른 타입들이 어떻게 처리되는지에 대한 규칙을 "거짓성(falsiness)"이라고 하며, 여기서 이를 구현합니다.
add after peek()
static bool isFalsey(Value value) { return IS_NIL(value) || (IS_BOOL(value) && !AS_BOOL(value)); }
Lox는 Ruby와 마찬가지로 nil과 false는 거짓(falsey)이며, 다른 모든 값은 true처럼 동작합니다. 생성할 수 있는 새로운 명령어가 생겼으니, 디스어셈블러에서도 이를 역생성할 수 있어야 합니다.
case OP_DIVIDE:
return simpleInstruction("OP_DIVIDE", offset);
in disassembleInstruction()
case OP_NOT: return simpleInstruction("OP_NOT", offset);
case OP_NEGATE:
18 . 4 . 2동등 및 비교 연산자
그리 나쁘지 않았습니다. 이제 동등 및 비교 연산자들도 처리하여 흐름을 이어갑시다: ==, !=, <, >, <=, >=. 이는 논리 연산자 and와 or를 제외하고 불리언 결과를 반환하는 모든 연산자를 포함합니다. and와 or는 단축 평가(short-circuit, 기본적으로 작은 제어 흐름을 필요로 함)를 해야 하므로 아직 준비되지 않았습니다.
다음은 이 연산자들을 위한 새로운 명령어들입니다.
OP_FALSE,
in enum OpCode
OP_EQUAL, OP_GREATER, OP_LESS,
OP_ADD,
잠깐, 세 개뿐인가요? !=, <=, >=는요? 이들도 명령어를 만들 수 있었습니다. 솔직히, 그렇게 하면 VM이 더 빠르게 실행될 것이므로, 목표가 성능이라면 그렇게 해야 합니다.
하지만 제 주요 목표는 바이트코드 컴파일러에 대해 가르치는 것입니다. 바이트코드 명령어가 사용자 소스 코드를 밀접하게 따를 필요가 없다는 생각을 여러분이 내면화하기를 바랍니다. VM은 올바른 사용자 가시적 동작을 하는 한, 어떤 명령어 세트와 코드 시퀀스든 자유롭게 사용할 수 있습니다.
a != b라는 표현식은 !(a == b)와 동일한 의미를 가지므로, 컴파일러는 전자를 후자인 것처럼 자유롭게 컴파일할 수 있습니다. 전용 OP_NOT_EQUAL 명령어 대신, OP_EQUAL 뒤에 OP_NOT을 출력할 수 있습니다. 마찬가지로, a <= b는 !(a > b)와 동일하며, a >= b는 !(a < b)와 같습니다. 따라서 세 개의 새로운 명령어만 있으면 됩니다.
하지만 파서에서는 파싱 테이블에 슬롯을 채워야 할 여섯 가지 새로운 연산자가 있습니다. 이전과 동일한 binary() 파서 함수를 사용합니다. 다음은 !=에 대한 행입니다.
[TOKEN_BANG] = {unary, NULL, PREC_NONE},
replace 1 line
[TOKEN_BANG_EQUAL] = {NULL, binary, PREC_EQUALITY},
[TOKEN_EQUAL] = {NULL, NULL, PREC_NONE},
나머지 다섯 연산자는 테이블에서 조금 더 아래쪽에 있습니다.
[TOKEN_EQUAL] = {NULL, NULL, PREC_NONE},
replace 5 lines
[TOKEN_EQUAL_EQUAL] = {NULL, binary, PREC_EQUALITY}, [TOKEN_GREATER] = {NULL, binary, PREC_COMPARISON}, [TOKEN_GREATER_EQUAL] = {NULL, binary, PREC_COMPARISON}, [TOKEN_LESS] = {NULL, binary, PREC_COMPARISON}, [TOKEN_LESS_EQUAL] = {NULL, binary, PREC_COMPARISON},
[TOKEN_IDENTIFIER] = {NULL, NULL, PREC_NONE},
binary() 내부에는 이미 각 토큰 타입에 맞는 바이트코드를 생성하기 위한 switch 문이 있습니다. 여섯 가지 새로운 연산자에 대한 케이스를 추가합니다.
switch (operatorType) {
in binary()
case TOKEN_BANG_EQUAL: emitBytes(OP_EQUAL, OP_NOT); break; case TOKEN_EQUAL_EQUAL: emitByte(OP_EQUAL); break; case TOKEN_GREATER: emitByte(OP_GREATER); break; case TOKEN_GREATER_EQUAL: emitBytes(OP_LESS, OP_NOT); break; case TOKEN_LESS: emitByte(OP_LESS); break; case TOKEN_LESS_EQUAL: emitBytes(OP_GREATER, OP_NOT); break;
case TOKEN_PLUS: emitByte(OP_ADD); break;
==, <, > 연산자는 단일 명령어를 출력합니다. 다른 연산자들은 역 연산을 평가하는 명령어와 그 결과를 뒤집는 OP_NOT 명령어를 한 쌍으로 출력합니다. 세 가지 명령어 가격으로 여섯 가지 연산자를 얻는 셈이죠!
이는 VM에서 우리의 작업이 더 간단하다는 것을 의미합니다. 동등성(Equality)은 가장 일반적인 연산입니다.
case OP_FALSE: push(BOOL_VAL(false)); break;
in run()
case OP_EQUAL: { Value b = pop(); Value a = pop(); push(BOOL_VAL(valuesEqual(a, b))); break; }
case OP_ADD: BINARY_OP(NUMBER_VAL, +); break;
어떤 종류의 객체라도, 심지어 타입이 다른 객체라도 == 연산을 평가할 수 있습니다. 복잡성이 상당하므로, 그 로직을 별도의 함수로 옮기는 것이 합리적입니다. 이 함수는 항상 C bool을 반환하므로, 결과를 BOOL_VAL로 안전하게 래핑할 수 있습니다. 이 함수는 Value와 관련이 있으므로, "value" 모듈에 존재합니다.
} ValueArray;
add after struct ValueArray
bool valuesEqual(Value a, Value b);
void initValueArray(ValueArray* array);
그리고 여기에 구현이 있습니다.
add after printValue()
bool valuesEqual(Value a, Value b) { if (a.type != b.type) return false; switch (a.type) { case VAL_BOOL: return AS_BOOL(a) == AS_BOOL(b); case VAL_NIL: return true; case VAL_NUMBER: return AS_NUMBER(a) == AS_NUMBER(b); default: return false; // 도달 불가능. } }
먼저 타입을 확인합니다. Value의 타입이 다르면, 확실히 같지 않습니다. 그렇지 않으면, 두 Value를 풀고 직접 비교합니다.
각 값 타입에 대해 값을 비교하는 별도의 케이스가 있습니다. 케이스들이 얼마나 유사한지를 고려할 때, 단순히 두 Value 구조체를 memcmp()하고 끝낼 수 없는 이유가 궁금할 것입니다. 문제는 패딩과 크기가 다른 유니온 필드 때문에 Value에 사용되지 않는 비트가 포함되어 있다는 것입니다. C는 그 비트 안에 무엇이 들어있는지 보장하지 않으므로, 두 개의 동일한 Value가 실제로는 사용되지 않는 메모리에서 다를 수 있습니다.
(이 사실을 알기 전까지 제가 얼마나 많은 고통을 겪었는지 믿지 못할 겁니다.)
어쨌든, clox에 더 많은 타입을 추가함에 따라 이 함수는 새로운 케이스들을 가질 것입니다. 지금은 이 세 가지로 충분합니다. 다른 비교 연산자들은 숫자에서만 작동하므로 더 쉽습니다.
push(BOOL_VAL(valuesEqual(a, b)));
break;
}
in run()
case OP_GREATER: BINARY_OP(BOOL_VAL, >); break; case OP_LESS: BINARY_OP(BOOL_VAL, <); break;
case OP_ADD: BINARY_OP(NUMBER_VAL, +); break;
우리는 이미 BINARY_OP 매크로를 비숫자 타입을 반환하는 연산자를 처리하도록 확장했습니다. 이제 그것을 사용할 차례입니다. 결과 값 타입이 불리언이므로 BOOL_VAL을 전달합니다. 그렇지 않으면 더하기나 빼기와 다르지 않습니다.
항상 그렇듯이, 오늘 아리아의 코다는 새로운 명령어를 디스어셈블하는 것입니다.
case OP_FALSE:
return simpleInstruction("OP_FALSE", offset);
in disassembleInstruction()
case OP_EQUAL: return simpleInstruction("OP_EQUAL", offset); case OP_GREATER: return simpleInstruction("OP_GREATER", offset); case OP_LESS: return simpleInstruction("OP_LESS", offset);
case OP_ADD:
이것으로 우리의 숫자 계산기는 이제 일반적인 표현식 평가기에 더 가까워졌습니다. clox를 실행하고 다음을 입력해보세요.
!(5 - 4 > 3 * 2 == !nil)
네, 가장 유용한 표현식은 아닐 수도 있지만, 우리는 진전을 이루고 있습니다. 아직 자체 리터럴 형태를 가진 내장 타입 하나가 빠져 있습니다: 문자열. 문자열은 크기가 가변적이기 때문에 훨씬 더 복잡합니다. 이 작은 차이는 문자열에 자체 장을 할애할 만큼 큰 영향을 미칩니다.
도전 과제
-
이항 연산자를 여기서 했던 것보다 더 줄일 수 있습니다. 다른 어떤 명령어를 제거할 수 있으며, 컴파일러는 그 부재에 어떻게 대처해야 할까요?
-
반대로, 더 높은 수준의 연산에 해당하는 특정 명령어를 추가하여 바이트코드 VM의 속도를 향상시킬 수 있습니다. 이번 장에서 지원을 추가한 종류의 사용자 코드를 가속화하기 위해 어떤 명령어를 정의하시겠습니까?