C 언어로 Lox 인터프리터 만들기 6

Will-Big·2025년 12월 1일

Interpreter

목록 보기
6/8

지난 시간까지는 double 타입의 숫자를 다룰 수 있는 기본적인 형태를 만들었습니다. 이번에는 true, false, nil 과 같은 숫자 외에 프로그래밍에 필요한 데이터 타입을 정의합니다.

이번 시간의 목표는 앞서 말한 데이터 타입들의 정의와 이를 이용한 비교 연산(<, >, ==)과 논리 연산(!)을 수행할 수 있도록 확장하는 것입니다.

Value 구조체의 변경: union

가장 근본적인 변화는 데이터를 담는 그릇인 Value 타입의 정의입니다.

// value.h - 이전
typedef double Value;

기존에는 모든 값을 숫자로만 취급했기에 double 그 자체였습니다.

// value.h - 현재
typedef enum {
    VAL_BOOL,
    VAL_NIL,
    VAL_NUMBER,
} ValueType;

typedef struct {
    ValueType type; // 값의 종류를 나타내는 태그
    union {
        bool boolean;
        double number;
    } as; // 실제 데이터
} Value;

이제는 값이 숫자인지, 불리언인지, 혹은 nil인지 구분해야 합니다. C 언어는 정적 타입 언어이므로, 동적 타입인 Lox 의 값을 담기 위해 union을 도입했습니다.

Value는 타입을 외부에 전달하기 위한 type과 실제 값을 가지고 있는 as를 가지고 있습니다.

이를 안전하게 다루기 위해 값을 확인(IS_BOOL)하고, C 언어의 기본 타입으로 변환(AS_BOOL)하고, 다시 Lox 값으로 포장(BOOL_VAL)하는 매크로들도 대거 추가되었습니다. 아래와 같이 사용할 수 있습니다.

// vm.c
static bool isFalsey(Value value) {
    return IS_NIL(value) || (IS_BOOL(value) && !AS_BOOL(value));
}

*값 동일 비교

메모리에 적힌 두 값이 다른지 가장 빠르게 비교하는 방법은 무엇일까요? 아마도 답은 메모리를 직접 비교(memcmp)하는 것일 겁니다. 비교 함수를 호출할 필요도 없이 말입니다. 하지만 clox에서는 불가능했습니다. 아래는 clox의 값이 같은지 검사하는 코드입니다.

// value.c
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 ; // 도달할 수 없음. 
  } 
}

이렇게 했던 이유는 아래 그림을 보며 설명하겠습니다.

이미지 출처: Crafting Interpreters

C 언어에서는 사용하지 않는 비트에 대해서 동일한 값이 들어있음을 보장하지 않습니다. 그리고 Value의 특성상 패딩으로 인해 빈 메모리 공간은 존재합니다. 이 패딩 안의 값은 동일한 타입이라도 다른 값이 들어있을 수 있어 memcmp를 사용할 수 없었습니다. 저자는 수 많은 디버깅을 통해 이 결과를 알아냈지만 우리는 그 결과를 쉽게 받아들일 수 있음에 큰 감사를 보냅니다.

명령어(OpCode)와 컴파일러의 확장

새로운 타입이 생겼으니, 이를 처리할 새로운 명령어도 필요합니다. 산술 연산만 있던 Chunk에 논리 및 비교 연산이 추가되었습니다.

// chunk.h
typedef enum
{
    OP_CONSTANT,
    OP_NIL,
    OP_TRUE,
    OP_FALSE,
    OP_EQUAL,
    OP_GREATER,
    OP_LESS,
    ...
    OP_NOT,
    OP_NEGATE,
    OP_RETURN,
} OpCode;
  • 리터럴 생성: OP_NIL, OP_TRUE, OP_FALSE
  • 비교 연산: OP_EQUAL (==), OP_GREATER (>), OP_LESS (<)
  • 논리 부정: OP_NOT (!)
// compiler.c
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; // Unreachable.
    }
}

number()가 숫자를 상수 테이블에 넣었다면, literal()은 고정된 값(true, false, nil)에 대응하는 특정 명령어를 바로 내보냅니다. 이 함수를 통해 상수 테이블을 쓰지 않고 직접 스택에 푸시합니다.

비교 연산(>, <)은 결과로 VAL_BOOL 타입의 값을 스택에 푸시합니다.

파싱 규칙(rules) 업데이트

프랫 파서의 rules 테이블에 새로운 토큰들을 등록했습니다. 이제 !unary로, 비교 연산자들은 binary로 연결되어 우선순위에 따라 파싱됩니다.

// compiler.c
ParseRule rules[] =
{
    [TOKEN_BANG]          = {unary,    NULL,   PREC_NONE},		// !
    [TOKEN_BANG_EQUAL]    = {NULL,     binary, PREC_EQUALITY},	// !=
    [TOKEN_EQUAL]         = {NULL,     NULL,   PREC_NONE},
    [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, 
    ...
    [TOKEN_EOF]           = {NULL,     NULL,   PREC_NONE},
};

VM: 타입 검사와 런타임 에러

가장 큰 변화는 가상 머신(vm.c)에 있습니다. 이전에는 무조건 pop()해서 더하거나 빼면 됐지만, 이제는 "숫자가 아닌 것에 뺄셈을 시도하는지" 감시해야 합니다.

타입 안전성 확보

BINARY_OP 매크로가 단순히 연산만 하는 것에서, 피연산자의 타입을 검사하는 로직으로 변경되었습니다.

// vm.c
#define BINARY_OP(valueType, op) \
    do { \
        if (!IS_NUMBER(peek(0)) || !IS_NUMBER(peek(1))) { \
            runtimeError("Operands must be numbers."); \
            return INTERPRET_RUNTIME_ERROR; \
        } \
        // ... 연산 수행 
    } while (false) \

이제 false + 5 같은 코드를 실행하면 VMOperands must be numbers. 라는 런타임 에러를 뱉고 안전하게 종료됩니다.

Falsey의 개념 (nilfalse)

Lox 언어에서 "거짓"으로 취급되는 것은 nilfalse 두 가지뿐입니다. 이를 처리하기 위한 앞서 Value 구조체 설명에서 나왔던 isFalsey 함수가 추가되었고, OP_NOT (!) 연산에서 이를 활용합니다.

실행 결과

이제 clox는 다음과 같은 복합적인 식을 이해하고 실행할 수 있습니다.

> !(5 - 4 > 1)
true
  1. 5 - 4를 계산하여 1을 얻습니다.

  2. 1 > 1을 비교하여 false를 얻습니다.

  3. !false를 수행하여 최종적으로 true가 출력됩니다.

마치며

이번 시간은 지난 포스팅보다 쉬웠습니다. 그리고 좀 더 '굴러가는' 듯한 느낌을 주는 과정이었습니다. 하지만 모든 과정이 지난 포스팅의 '재귀 함수를 통해 인터프리터가 작동하는 원리' 를 이해하는 것을 기조로 하고 있습니다.

재미있던 점은, C 언어의 특성으로 인해 memcmp로 빠르게 가능할 것이라 여겼던 동일 비교 연산이 그렇지 않았다는 것입니다. 패딩의 여백 부분의 동일함을 보장되지 않아 어쩔 수 없이 AS_BOOL, AS_NUMBER 와 같은 매크로 연산이 불가피했던 것은 몰랐던 내용이었습니다.

현재는 숫자와 연산자로 이루어진 식을 이해하고 결과를 도출하는 인터프리터를 만들었습니다. 다음 시간에는 문자열을 상수 컨테이너에 넣고, 정상적으로 해제하는 구조를 만들어보겠습니다.

참고 자료: Cratring Interpreters - types of values
Github: Will-big/clox

profile
개발자가 되고싶어요

0개의 댓글