Ch18까지는 숫자, bool, nil 같이 크기가 고정된 값만 다뤘다.
이번 챕터에서는 가변 길이 데이터인 문자열을 추가한다.
문자열이 왜 특별한가? 지금까지의 Value는 이렇게 생겼다.
typedef union {
bool boolean;
double number;
} as;
bool은 1바이트, double은 8바이트 — 크기가 컴파일 타임에 고정이라 Value 안에 직접 담을 수 있었다.
하지만 문자열은 다르다.
"hi" → 2글자
"hello, world!" → 14글자
길이가 런타임에 결정되므로 Value 안에 직접 담을 수 없다.
그래서 Value에는 포인터만 넣고, 실제 데이터는 힙에 할당한다.
문자열 외에도 앞으로 함수, 클래스 등 다양한 힙 객체가 생긴다.
이것들을 하나의 Value로 다루기 위해 공통 베이스 타입 Obj를 도입한다.
// object.h
typedef enum {
OBJ_STRING,
} ObjType;
struct Obj {
ObjType type;
struct Obj* next;
};
type 필드는 이 포인터가 실제로 어떤 객체인지 나타내는 태그다.
next는 VM이 할당한 모든 객체를 링크드 리스트로 연결하기 위한 포인터다. 나중에 GC(가비지 컬렉터)가 이 리스트를 순회해 메모리를 해제한다.
vm.objects → [ObjString "world"] → [ObjString "hello"] → NULL
C에는 상속이 없지만 구조체 레이아웃을 이용해 흉내낼 수 있다.
C 스펙 보장: 구조체의 첫 번째 필드는 구조체 자체와 메모리 주소가 동일하다.
struct ObjString {
Obj obj; // 반드시 첫 번째 필드
int length;
char* chars;
};
메모리 레이아웃:
ObjString 주소: 0x3000
┌──────────────────────────────┐
│ Obj obj (ObjType type) │ ← 0x3000 (Obj*와 주소 동일)
│ (Obj* next) │
│ int length │ ← 0x3008
│ char* chars │ ← 0x300C
└──────────────────────────────┘
ObjString*를 Obj*로 캐스팅해도 같은 주소를 가리키므로 안전하다.
책에서는 이 패턴을 "struct inheritance" 라고 부른다.
// value.h
typedef struct Obj Obj; // 전방 선언
typedef struct ObjString ObjString;
typedef enum {
VAL_BOOL,
VAL_NIL,
VAL_NUMBER,
VAL_OBJ, // ← 추가
} ValueType;
typedef struct {
ValueType type;
union {
bool boolean;
double number;
Obj* obj; // ← 추가
} as;
} Value;
접근 매크로:
#define IS_OBJ(value) ((value).type == VAL_OBJ)
#define AS_OBJ(value) ((value).as.obj)
#define OBJ_VAL(value) ((Value){VAL_OBJ, {.obj = (Obj*)(value)}})
object.h에 문자열 전용 매크로를 추가한다.
#define OBJ_TYPE(value) (AS_OBJ(value)->type)
#define IS_STRING(value) isObjType(value, OBJ_STRING)
#define AS_STRING(value) ((ObjString*)AS_OBJ(value))
#define AS_CSTRING(value) (((ObjString*)AS_OBJ(value))->chars)
IS_STRING을 매크로 대신 inline 함수로 구현한 이유:
// 매크로였다면 pop()이 두 번 호출됨 💥
#define IS_STRING(v) (IS_OBJ(v) && AS_OBJ(v)->type == OBJ_STRING)
IS_STRING(pop())
// inline 함수는 인자를 한 번만 평가
static inline bool isObjType(Value value, ObjType type) {
return IS_OBJ(value) && AS_OBJ(value)->type == type;
}
문자열을 힙에 올리는 함수가 두 가지다.
// 소스코드 리터럴용: 새 힙 메모리에 복사
ObjString* copyString(const char* chars, int length) {
char* heapChars = ALLOCATE(char, length + 1);
memcpy(heapChars, chars, length);
heapChars[length] = '\0';
return allocateString(heapChars, length);
}
// 문자열 연결 결과용: 이미 힙에 있으니 소유권만 인수
ObjString* takeString(char* chars, int length) {
return allocateString(chars, length);
}
| copyString | takeString | |
|---|---|---|
| 사용처 | 소스코드 리터럴 "hello" | 문자열 연결 "a" + "b" 결과 |
| 메모리 | 새로 할당 후 복사 | 기존 버퍼 소유권 인수 |
| 이유 | 원본 소스 버퍼는 나중에 사라질 수 있음 | concatenate()가 이미 새 버퍼를 만들었음 |
할당 후 포인터 구조:
Value { type: VAL_OBJ, as.obj: 0x3000 }
↓
ObjString { chars: 0x2000 }
↓
char[] "hello\0"
Pratt Parser 규칙 테이블에 문자열 핸들러를 등록한다.
// compiler.c
[TOKEN_STRING] = {string, NULL, PREC_NONE},
static void string() {
emitConstant(OBJ_VAL(copyString(parser.previous.start + 1,
parser.previous.length - 2)));
}
Scanner가 "hello"를 토큰으로 만들면 따옴표가 포함된다.
Token { start: → "hello", length: 7 }
start + 1 → 여는 " 건너뜀length - 2 → 양쪽 " 2글자 제외OP_ADD를 타입에 따라 분기하도록 수정한다.
case OP_ADD: {
if (IS_STRING(peek(0)) && IS_STRING(peek(1))) {
concatenate();
} else if (IS_NUMBER(peek(0)) && IS_NUMBER(peek(1))) {
double b = AS_NUMBER(pop());
double a = AS_NUMBER(pop());
push(NUMBER_VAL(a + b));
} else {
runtimeError("Operands must be two numbers or two strings.");
return INTERPRET_RUNTIME_ERROR;
}
break;
}
static void concatenate() {
ObjString* b = AS_STRING(pop());
ObjString* a = AS_STRING(pop());
int length = a->length + b->length;
char* chars = ALLOCATE(char, length + 1);
memcpy(chars, a->chars, a->length);
memcpy(chars + a->length, b->chars, b->length);
chars[length] = '\0';
ObjString* result = takeString(chars, length);
push(OBJ_VAL(result));
}
프로그램 종료 시 링크드 리스트를 순회하며 모든 객체를 해제한다.
void freeObjects() {
Obj* object = vm.objects;
while (object != NULL) {
Obj* next = object->next; // 먼저 저장
freeObject(object); // 그 다음 해제
object = next;
}
}
static void freeObject(Obj* object) {
switch (object->type) {
case OBJ_STRING: {
ObjString* string = (ObjString*)object;
FREE_ARRAY(char, string->chars, string->length + 1);
FREE(ObjString, object);
break;
}
}
}
ObjString은 chars 버퍼와 ObjString 자체, 두 번 해제한다.



이번 포스트부터는 claude code를 활용하여 함께 코드를 이해하고 포스트를 작성하고 있다. clox의 규모가 커지며 한번에 알아보기 어려웠던 실행 흐름을 어느정도 잡아주고 코드가 기존과 비교하여 추가된 부분을 중점적으로 리뷰해주기 때문에 확실히 이해하기 쉬워졌다.
참고 자료: Cratring Interpreters - strings
Github: Will-big/clox