19

문자열

“아? 사소한 잡무에 대한 작은 혐오감이라고요?” 의사는 눈썹을 치켜떴다. “이해는 하지만, 잘못 짚었군요. 몸은 바쁘게 움직이지만 마음과 영혼은 자유롭게 두는 그런 평범한 일들을 소중히 여겨야 합니다.”

태드 윌리엄스, The Dragonbone Chair

현재 우리 VM은 숫자, 불리언, nil 세 가지 타입의 값을 표현할 수 있습니다. 이 타입들은 두 가지 중요한 공통점을 가지고 있습니다. 바로 변경 불가능(immutable)하며 크기가 작다는 점입니다. 숫자 타입이 가장 크지만, 여전히 64비트 워드 두 개 안에 들어갑니다. 이는 불리언이나 nil처럼 많은 공간을 필요로 하지 않는 값들에도 충분히 감당할 수 있는 작은 비용입니다.

아쉽게도 문자열은 그렇게 작지 않습니다. 문자열에는 최대 길이가 정해져 있지 않습니다. 255자 같은 인위적인 제한을 두더라도, 모든 개별 값에 그렇게 많은 메모리를 할당하는 것은 여전히 과도합니다.

크기가 다양하고 때로는 매우 큰 값을 지원할 방법이 필요합니다. 이는 힙(heap)에서의 동적 할당이 정확히 이러한 목적을 위해 설계된 것입니다. 필요한 만큼 바이트를 할당할 수 있으며, VM을 통해 값이 흐르는 동안 이 값을 추적하는 데 사용할 포인터를 받게 됩니다.

19 . 1값과 객체

더 크고 가변적인 크기의 값에는 힙을 사용하고, 더 작고 원자적인 값에는 스택을 사용하는 방식은 두 단계의 표현 방식으로 이어집니다. 변수에 저장하거나 표현식에서 반환할 수 있는 모든 Lox 값은 Value가 될 것입니다. 숫자와 같이 작고 고정된 크기의 타입의 경우, 페이로드는 Value 구조체 내부에 직접 저장됩니다.

객체가 더 크다면, 해당 데이터는 힙에 존재합니다. 그러면 Value의 페이로드는 해당 메모리 덩어리를 가리키는 *포인터*가 됩니다. 결국 clox에는 문자열, 인스턴스, 함수 등 몇 가지 힙 할당 타입이 생길 것입니다. 각 타입은 고유한 데이터를 가지지만, 미래의 가비지 컬렉터가 메모리를 관리하는 데 사용할 공통 상태도 공유합니다.

숫자 및 객체 값의 필드 레이아웃.

이러한 공통 표현을 “Obj”라고 부를 것입니다. 상태가 힙에 존재하는 모든 Lox 값은 Obj입니다. 따라서 모든 힙 할당 타입을 참조하기 위해 단일한 새로운 ValueType 케이스를 사용할 수 있습니다.

  VAL_NUMBER,
value.h
in enum ValueType
  VAL_OBJ
} ValueType;
value.h, in enum ValueType

Value의 타입이 VAL_OBJ일 때, 페이로드는 힙 메모리를 가리키는 포인터이므로, 이를 위한 또 다른 케이스를 유니온에 추가합니다.

    double number;
value.h
in struct Value
    Obj* obj;
  } as; 
value.h, in struct Value

다른 값 타입에서 했던 것처럼, Obj 값 작업을 위한 몇 가지 유용한 매크로를 만듭니다.

#define IS_NUMBER(value)  ((value).type == VAL_NUMBER)
value.h
add after struct Value
#define IS_OBJ(value)     ((value).type == VAL_OBJ)

#define AS_BOOL(value)    ((value).as.boolean)
value.h, add after struct Value

이는 주어진 Value가 Obj인 경우 `true`로 평가됩니다. 그렇다면 다음을 사용할 수 있습니다.

#define IS_OBJ(value)     ((value).type == VAL_OBJ)

value.h
#define AS_OBJ(value)     ((value).as.obj)
#define AS_BOOL(value)    ((value).as.boolean)
value.h

이는 값에서 Obj 포인터를 추출합니다. 반대 방향으로도 갈 수 있습니다.

#define NUMBER_VAL(value) ((Value){VAL_NUMBER, {.number = value}})
value.h
#define OBJ_VAL(object)   ((Value){VAL_OBJ, {.obj = (Obj*)object}})

typedef struct {
value.h

이는 순수한 Obj 포인터를 받아 완전한 Value로 감쌉니다.

19 . 2구조체 상속

모든 힙 할당 값은 Obj이지만, Obj는 모두 같지 않습니다. 문자열의 경우 문자 배열이 필요합니다. 인스턴스를 다룰 때는 해당 데이터 필드가 필요할 것입니다. 함수 객체에는 바이트코드 덩어리가 필요할 것입니다. 다양한 페이로드와 크기를 어떻게 처리할까요? 크기가 모두 제각각이므로 Value에서 사용했던 것처럼 또 다른 유니온을 사용할 수는 없습니다.

대신 다른 기술을 사용할 것입니다. 이는 C 언어 표준이 이를 위한 특정 지원을 명시할 정도로 오래된 기술이지만, 공식적인 이름이 있는지는 모르겠습니다. 이는 타입 펀닝(type punning)의 한 예이지만, 그 용어는 너무 광범위합니다. 더 나은 아이디어가 없으므로, 구조체(struct)에 의존하고 객체 지향 언어에서 상태의 단일 상속이 작동하는 방식과 대략적으로 유사하기 때문에 **구조체 상속(struct inheritance)**이라고 부르겠습니다.

태그가 지정된 유니온처럼, 각 Obj는 해당 객체가 어떤 종류인지(문자열, 인스턴스 등) 식별하는 태그 필드로 시작합니다. 그 다음에는 페이로드 필드가 따라옵니다. 각 타입에 대한 케이스를 가진 유니온 대신, 각 타입은 자체적인 별도의 구조체입니다. 까다로운 부분은 C언어에는 상속이나 다형성 개념이 없기 때문에 이 구조체들을 균일하게 다루는 방법입니다. 곧 설명하겠지만, 먼저 예비 작업을 진행하겠습니다.

“Obj”라는 이름 자체는 모든 객체 타입에 걸쳐 공유되는 상태를 포함하는 구조체를 의미합니다. 이는 객체들의 “기반 클래스”와 같은 역할을 합니다. 값과 객체 사이에 순환 의존성이 있기 때문에, “value” 모듈에서 이를 전방 선언(forward-declare)합니다.

#include "common.h"

value.h
typedef struct Obj Obj;

typedef enum {
value.h

실제 정의는 새로운 모듈에 있습니다.

object.h
create new file
#ifndef clox_object_h
#define clox_object_h

#include "common.h"
#include "value.h"

struct Obj {
  ObjType type;
};

#endif
object.h, create new file

현재는 타입 태그만 포함하고 있습니다. 곧 메모리 관리를 위한 다른 부가 정보들을 추가할 것입니다. 타입 열거형은 다음과 같습니다.

#include "value.h"
object.h

typedef enum {
  OBJ_STRING,
} ObjType;

struct Obj {
object.h

물론, 이는 나중에 더 많은 힙 할당 타입을 추가한 후의 챕터에서 더 유용할 것입니다. 이 태그 타입들에 자주 접근할 것이기 때문에, 주어진 Value에서 객체 타입 태그를 추출하는 작은 매크로를 만드는 것이 좋습니다.

#include "value.h"
object.h

#define OBJ_TYPE(value)        (AS_OBJ(value)->type)

typedef enum {
object.h

이것이 우리의 기반입니다.

이제 그 위에 문자열을 구축해 봅시다. 문자열의 페이로드는 별도의 구조체로 정의됩니다. 다시 한 번, 이를 전방 선언해야 합니다.

typedef struct Obj Obj;
value.h
typedef struct ObjString ObjString;

typedef enum {
value.h

정의는 Obj와 함께 있습니다.

};
object.h
add after struct Obj

struct ObjString {
  Obj obj;
  int length;
  char* chars;
};

#endif
object.h, add after struct Obj

문자열 객체는 문자 배열을 포함합니다. 이 문자들은 별도로 힙에 할당된 배열에 저장되므로, 각 문자열에 필요한 만큼의 공간만 할당할 수 있습니다. 또한 배열의 바이트 수를 저장합니다. 이는 엄밀히 필수적인 것은 아니지만, 널(null) 종료 문자를 찾기 위해 문자 배열을 순회하지 않고도 문자열에 할당된 메모리 양을 알 수 있게 해줍니다.

ObjString은 Obj이므로, 모든 Obj가 공유하는 상태도 필요합니다. 이를 위해 첫 번째 필드가 Obj가 되도록 합니다. C 표준은 구조체 필드가 선언된 순서대로 메모리에 배치되도록 명시합니다. 또한, 구조체를 중첩하면 내부 구조체의 필드가 제자리에서 확장됩니다. 따라서 Obj와 ObjString의 메모리 구조는 다음과 같습니다.

Obj와 ObjString 필드의 메모리 레이아웃.

ObjString의 첫 바이트가 Obj와 정확히 일치하는 방식에 주목하십시오. 이는 우연이 아니며C 표준이 이를 강제합니다. 이는 영리한 패턴을 가능하게 하도록 설계되었습니다. 구조체에 대한 포인터를 가져와서 첫 번째 필드에 대한 포인터로 안전하게 변환했다가 다시 되돌릴 수 있습니다.

`ObjString*`가 주어지면, 이를 안전하게 `Obj*`로 캐스팅한 다음 `type` 필드에 접근할 수 있습니다. 모든 ObjString은 OOP의 “is-a(A는 B이다)” 의미에서 Obj “입니다”. 나중에 다른 객체 타입을 추가할 때, 각 구조체는 Obj를 첫 번째 필드로 가질 것입니다. 모든 객체와 함께 작동하려는 코드는 이들을 기본 `Obj*`로 취급하고, 그 뒤에 올 수 있는 다른 필드들은 무시할 수 있습니다.

반대 방향으로도 갈 수 있습니다. `Obj*`가 주어지면, 이를 `ObjString*`로 “다운캐스팅”할 수 있습니다. 물론, 가지고 있는 `Obj*` 포인터가 실제 ObjString의 `obj` 필드를 가리키는지 확인해야 합니다. 그렇지 않으면, 임의의 메모리 비트를 안전하지 않게 재해석하는 것입니다. 이러한 캐스트가 안전한지 감지하기 위해, 또 다른 매크로를 추가합니다.

#define OBJ_TYPE(value)        (AS_OBJ(value)->type)
object.h

#define IS_STRING(value)       isObjType(value, OBJ_STRING)

typedef enum {
object.h

이는 raw `Obj*`가 아닌 Value를 받습니다. VM의 대부분의 코드가 Value로 작동하기 때문입니다. 이는 다음 인라인 함수에 의존합니다.

};

object.h
add after struct ObjString
static inline bool isObjType(Value value, ObjType type) {
  return IS_OBJ(value) && AS_OBJ(value)->type == type;
}

#endif
object.h, add after struct ObjString

돌발 퀴즈: 이 함수의 본문을 매크로 안에 바로 넣지 않는 이유는 무엇일까요? 다른 매크로들과 비교했을 때 이 매크로의 차이점은 무엇일까요? 맞습니다, 본문에서 `value`를 두 번 사용하기 때문입니다. 매크로는 본문에 매개변수 이름이 나타나는 모든 위치에 인자 *표현식*을 삽입하여 확장됩니다. 만약 매크로가 매개변수를 두 번 이상 사용하면, 해당 표현식이 여러 번 평가됩니다.

만약 표현식에 부작용이 있다면 이는 좋지 않습니다. 만약 `isObjType()`의 본문을 매크로 정의에 넣고, 예를 들어 다음과 같이 사용한다면,

IS_STRING(POP())

스택에서 두 개의 값을 팝하게 될 것입니다! 함수를 사용하면 이 문제가 해결됩니다.

어떤 타입의 Obj를 생성할 때마다 타입 태그를 올바르게 설정하는 한, 이 매크로는 특정 객체 타입으로 값을 안전하게 캐스팅할 수 있는 시기를 알려줄 것입니다. 이는 다음을 사용하여 수행할 수 있습니다.

#define IS_STRING(value)       isObjType(value, OBJ_STRING)
object.h

#define AS_STRING(value)       ((ObjString*)AS_OBJ(value))
#define AS_CSTRING(value)      (((ObjString*)AS_OBJ(value))->chars)

typedef enum {
object.h

이 두 매크로는 힙에 있는 유효한 ObjString에 대한 포인터를 포함할 것으로 예상되는 Value를 받습니다. 첫 번째는 `ObjString*` 포인터를 반환하고, 두 번째는 해당 포인터를 통해 문자 배열 자체를 반환합니다. 이는 종종 우리가 최종적으로 필요로 하는 것이기 때문입니다.

19 . 3문자열

좋습니다, 이제 우리 VM은 문자열 값을 표현할 수 있습니다. 이제 언어 자체에 문자열을 추가할 때입니다. 늘 그렇듯이, 프런트엔드에서 시작합니다. 렉서(lexer)는 이미 문자열 리터럴을 토큰화했으므로, 파서(parser)의 차례입니다.

  [TOKEN_IDENTIFIER]    = {NULL,     NULL,   PREC_NONE},
compiler.c
replace 1 line
  [TOKEN_STRING]        = {string,   NULL,   PREC_NONE},
  [TOKEN_NUMBER]        = {number,   NULL,   PREC_NONE},
compiler.c, replace 1 line

파서가 문자열 토큰을 만나면, 다음 파싱 함수를 호출합니다.

compiler.c
add after number()
static void string() {
  emitConstant(OBJ_VAL(copyString(parser.previous.start + 1,
                                  parser.previous.length - 2)));
}
compiler.c, add after number()

이 함수는 문자열의 문자를 렉심에서 직접 가져옵니다. `+ 1`과 `- 2` 부분은 앞뒤의 따옴표를 잘라냅니다. 그런 다음 문자열 객체를 생성하고, Value로 감싼 후, 상수 테이블에 저장합니다.

문자열을 생성하기 위해 `object.h`에 선언된 `copyString()`을 사용합니다.

};

object.h
add after struct ObjString
ObjString* copyString(const char* chars, int length);

static inline bool isObjType(Value value, ObjType type) {
object.h, add after struct ObjString

컴파일러 모듈은 이를 포함해야 합니다.

#define clox_compiler_h

compiler.h
#include "object.h"
#include "vm.h"
compiler.h

우리 “object” 모듈은 새로운 함수를 정의할 구현 파일을 갖습니다.

object.c
create new file
#include <stdio.h>
#include <string.h>

#include "memory.h"
#include "object.h"
#include "value.h"
#include "vm.h"

ObjString* copyString(const char* chars, int length) {
  char* heapChars = ALLOCATE(char, length + 1);
  memcpy(heapChars, chars, length);
  heapChars[length] = '\0';
  return allocateString(heapChars, length);
}
object.c, create new file

먼저, 문자열의 문자들과 뒤따르는 종료 문자를 담을 만큼 충분히 큰 새 배열을 힙에 할당합니다. 이때 주어진 요소 타입과 개수로 배열을 할당하는 이 저수준 매크로를 사용합니다.

#include "common.h"

memory.h
#define ALLOCATE(type, count) \
    (type*)reallocate(NULL, 0, sizeof(type) * (count))

#define GROW_CAPACITY(capacity) \
memory.h

배열을 얻으면, 렉심에서 문자들을 복사하고 종료 문자를 추가합니다.

ObjString이 소스 문자열의 원래 문자를 단순히 가리킬 수 없는 이유가 궁금할 수 있습니다. 일부 ObjString은 문자열 연결과 같은 문자열 연산의 결과로 런타임에 동적으로 생성됩니다. 이러한 문자열은 문자를 위해 동적으로 메모리를 할당해야 하며, 이는 문자열이 더 이상 필요 없을 때 해당 메모리를 *해제*해야 한다는 의미입니다.

만약 문자열 리터럴에 대한 ObjString이 있고, 원래 소스 코드 문자열을 가리키는 문자 배열을 해제하려고 시도한다면, 좋지 않은 일이 발생할 것입니다. 따라서 리터럴의 경우, 문자를 미리 힙으로 복사합니다. 이렇게 하면 모든 ObjString이 문자 배열을 안정적으로 소유하고 해제할 수 있습니다.

문자열 객체를 생성하는 실제 작업은 이 함수에서 이루어집니다.

#include "vm.h"

object.c
static ObjString* allocateString(char* chars, int length) {
  ObjString* string = ALLOCATE_OBJ(ObjString, OBJ_STRING);
  string->length = length;
  string->chars = chars;
  return string;
}
object.c

이 함수는 힙에 새 ObjString을 생성하고 필드를 초기화합니다. OOP 언어의 생성자와 유사합니다. 따라서 먼저 “기반 클래스” 생성자를 호출하여 새 매크로를 사용하여 Obj 상태를 초기화합니다.

#include "vm.h"
object.c

#define ALLOCATE_OBJ(type, objectType) \
    (type*)allocateObject(sizeof(type), objectType)

static ObjString* allocateString(char* chars, int length) {
object.c

이전 매크로와 마찬가지로, 이 매크로는 주로 `void*`를 원하는 타입으로 중복해서 캐스팅해야 하는 필요성을 피하기 위해 존재합니다. 실제 기능은 여기에 있습니다.

#define ALLOCATE_OBJ(type, objectType) \
    (type*)allocateObject(sizeof(type), objectType)
object.c

static Obj* allocateObject(size_t size, ObjType type) {
  Obj* object = (Obj*)reallocate(NULL, 0, size);
  object->type = type;
  return object;
}

static ObjString* allocateString(char* chars, int length) {
object.c

이 함수는 주어진 크기의 객체를 힙에 할당합니다. 이때 크기는 Obj 자체의 크기만이 *아닙니다*. 호출자는 생성되는 특정 객체 타입에 필요한 추가 페이로드 필드를 위한 공간이 있도록 바이트 수를 전달합니다.

그런 다음 Obj 상태를 초기화합니다현재는 단순히 타입 태그입니다. 이 함수는 `allocateString()`으로 돌아가서 ObjString 필드 초기화를 완료합니다. Voilà, 이제 문자열 리터럴을 컴파일하고 실행할 수 있습니다.

19 . 4문자열 연산

우리의 멋진 문자열은 존재하지만, 아직 아무것도 하지 않습니다. 첫 번째 좋은 단계는 기존의 출력 코드가 새 값 타입에 대해 오류를 내지 않도록 하는 것입니다.

    case VAL_NUMBER: printf("%g", AS_NUMBER(value)); break;
value.c
in printValue()
    case VAL_OBJ: printObject(value); break;
  }
value.c, in printValue()

값이 힙 할당 객체인 경우, “object” 모듈의 헬퍼 함수로 작업을 위임합니다.

ObjString* copyString(const char* chars, int length);
object.h
add after copyString()
void printObject(Value value);

static inline bool isObjType(Value value, ObjType type) {
object.h, add after copyString()

구현은 다음과 같습니다.

object.c
add after copyString()
void printObject(Value value) {
  switch (OBJ_TYPE(value)) {
    case OBJ_STRING:
      printf("%s", AS_CSTRING(value));
      break;
  }
}
object.c, add after copyString()

현재는 단일 객체 타입만 있지만, 이 함수는 이후 챕터에서 추가적인 `switch` 케이스를 가질 것입니다. 문자열 객체의 경우, 단순히 문자 배열을 C 문자열로 출력합니다.

동등 연산자 또한 문자열을 우아하게 처리해야 합니다. 다음을 고려해 보십시오.

"string" == "string"

이것들은 두 개의 개별적인 문자열 리터럴입니다. 컴파일러는 `copyString()`을 두 번 호출하여 두 개의 서로 다른 ObjString 객체를 생성하고 청크에 두 개의 상수로 저장할 것입니다. 이들은 힙에서 서로 다른 객체입니다. 하지만 우리 사용자들(그리고 우리)은 문자열이 값 동등성(value equality)을 가지기를 기대합니다. 위 표현식은 `true`로 평가되어야 합니다. 이는 약간의 특별한 지원을 필요로 합니다.

    case VAL_NUMBER: return AS_NUMBER(a) == AS_NUMBER(b);
value.c
in valuesEqual()
    case VAL_OBJ: {
      ObjString* aString = AS_STRING(a);
      ObjString* bString = AS_STRING(b);
      return aString->length == bString->length &&
          memcmp(aString->chars, bString->chars,
                 aString->length) == 0;
    }
    default:         return false; // Unreachable.
value.c, in valuesEqual()

두 값이 모두 문자열인 경우, 두 개의 별개 객체이든 완전히 동일한 객체이든 관계없이 문자 배열에 동일한 문자가 포함되어 있다면 이들은 동일합니다. 이는 문자열 동등성 비교가 전체 문자열을 순회해야 하므로 다른 타입의 동등성 비교보다 느리다는 것을 의미합니다. 이 부분은 나중에 수정하겠지만, 지금으로서는 올바른 의미론을 제공합니다.

마지막으로, `memcmp()`와 “object” 모듈의 새로운 기능을 사용하기 위해서는 몇 가지 인클루드가 필요합니다. 여기에:

#include <stdio.h>
value.c
#include <string.h>

#include "memory.h"
value.c

그리고 여기에:

#include <string.h>

value.c
#include "object.h"
#include "memory.h"
value.c

19 . 4 . 1연결 (Concatenation)

완성된 언어는 문자열 작업을 위한 많은 연산을 제공합니다개별 문자 접근, 문자열 길이, 대소문자 변경, 분할, 결합, 검색 등. 여러분이 언어를 구현할 때, 이 모든 것을 원할 것입니다. 하지만 이 책에서는 *매우* 최소한으로 유지합니다.

우리가 문자열에 대해 지원하는 유일하게 흥미로운 연산은 `+`입니다. 이 연산자를 두 개의 문자열 객체에 사용하면, 두 피연산자를 연결한 새로운 문자열을 생성합니다. Lox는 동적 타입 언어이므로, 런타임에 피연산자의 타입을 알기 전까지는 컴파일 타임에 어떤 동작이 필요한지 알 수 없습니다. 따라서 `OP_ADD` 명령어는 피연산자를 동적으로 검사하여 올바른 연산을 선택합니다.

      case OP_LESS:     BINARY_OP(BOOL_VAL, <); break;
vm.c
in run()
replace 1 line
      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;
      }
      case OP_SUBTRACT: BINARY_OP(NUMBER_VAL, -); break;
vm.c, in run(), replace 1 line

두 피연산자가 모두 문자열이면 연결하고, 모두 숫자이면 더합니다. 다른 어떤 피연산자 타입의 조합도 런타임 오류입니다.

문자열을 연결하기 위해 새로운 함수를 정의합니다.

vm.c
add after isFalsey()
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));
}
vm.c, add after isFalsey()

문자열을 다루는 C 코드가 그렇듯이 꽤 장황합니다. 먼저, 피연산자의 길이를 기반으로 결과 문자열의 길이를 계산합니다. 결과 문자열을 위한 문자 배열을 할당한 다음, 두 절반을 복사합니다. 항상 그렇듯이, 문자열이 종료되도록 신중하게 확인합니다.

`memcpy()`를 호출하기 위해 VM은 인클루드를 필요로 합니다.

#include <stdio.h>
vm.c
#include <string.h>

#include "common.h"
vm.c

마지막으로, 이 문자들을 포함할 ObjString을 생성합니다. 이번에는 새로운 함수인 `takeString()`을 사용합니다.

};

object.h
add after struct ObjString
ObjString* takeString(char* chars, int length);
ObjString* copyString(const char* chars, int length);
object.h, add after struct ObjString

구현은 다음과 같습니다.

object.c
add after allocateString()
ObjString* takeString(char* chars, int length) {
  return allocateString(chars, length);
}
object.c, add after allocateString()

이전의 `copyString()` 함수는 전달받은 문자의 소유권을 가질 수 없다고 가정합니다. 대신, ObjString이 소유할 수 있는 문자 복사본을 힙에 보수적으로 생성합니다. 이는 전달된 문자가 소스 문자열 중간에 있는 문자열 리터럴에 적합한 방식입니다.

하지만 연결(concatenation)의 경우, 우리는 이미 힙에 문자 배열을 동적으로 할당했습니다. 그것을 또 다시 복사하는 것은 중복될 것입니다 (그리고 `concatenate()`가 자신의 복사본을 해제해야 한다는 것을 기억해야 한다는 의미가 될 것입니다). 대신, 이 함수는 주어진 문자열의 소유권을 주장합니다.

늘 그렇듯이, 이 기능을 함께 엮기 위해서는 몇 가지 인클루드가 필요합니다.

#include "debug.h"
vm.c
#include "object.h"
#include "memory.h"
#include "vm.h"
vm.c

19 . 5객체 해제

이 무해해 보이는 표현식을 보십시오.

"st" + "ri" + "ng"

컴파일러가 이를 처리할 때, 세 개의 문자열 리터럴 각각에 대해 ObjString을 할당하고 청크의 상수 테이블에 저장하며, 다음 바이트코드를 생성합니다.

0000    OP_CONSTANT         0 "st"
0002    OP_CONSTANT         1 "ri"
0004    OP_ADD
0005    OP_CONSTANT         2 "ng"
0007    OP_ADD
0008    OP_RETURN

첫 두 명령어는 "st""ri"를 스택에 푸시합니다. 그 다음 OP_ADD는 이들을 팝하고 연결합니다. 이 과정에서 힙에 새로운 "stri" 문자열이 동적으로 할당됩니다. VM은 이 문자열을 푸시한 다음, "ng" 상수를 푸시합니다. 마지막 OP_ADD"stri""ng"를 팝하고 연결하여 결과인 "string"을 푸시합니다. 좋습니다, 이것이 우리가 기대하는 바입니다.

하지만 잠시만요. 그 "stri" 문자열은 어떻게 되었을까요? 우리는 그것을 동적으로 할당했지만, VM은 "ng"와 연결한 후 버렸습니다. 스택에서 팝했으므로 더 이상 참조는 없지만, 메모리를 해제하지는 않았습니다. 고전적인 메모리 누수가 발생한 것입니다.

물론, Lox 프로그램이 중간 문자열을 잊어버리고 해제에 대해 걱정하지 않는 것은 완전히 괜찮습니다. Lox는 사용자를 대신하여 메모리를 자동으로 관리합니다. 메모리 관리 책임이 *사라지는* 것이 아닙니다. 대신, VM 구현자인 우리의 몫이 됩니다.

완전한 해결책은 프로그램이 실행되는 동안 사용되지 않는 메모리를 회수하는 가비지 컬렉터입니다. 그 프로젝트를 다룰 준비가 되기 전에 몇 가지 다른 것들을 제자리에 두어야 합니다. 그때까지 우리는 빌린 시간 속에 살고 있습니다. 컬렉터를 추가하는 것을 오래 기다릴수록 작업은 더 어려워집니다.

오늘, 우리는 최소한 다음을 해야 합니다. Lox 프로그램 자체가 더 이상 참조하지 않더라도 VM이 할당된 모든 객체를 여전히 찾을 수 있도록 하여 메모리 *누수*를 방지하는 것입니다. 고급 메모리 관리자는 객체에 대한 메모리를 할당하고 추적하는 데 많은 정교한 기술을 사용합니다. 우리는 가장 간단하고 실용적인 접근 방식을 취할 것입니다.

모든 Obj를 저장하는 연결 리스트를 만들 것입니다. VM은 이 리스트를 순회하여 사용자의 프로그램이나 VM의 스택이 해당 객체를 여전히 참조하든 그렇지 않든, 힙에 할당된 모든 객체를 찾을 수 있습니다.

별도의 연결 리스트 노드 구조체를 정의할 수도 있지만, 그렇게 되면 그 노드들까지도 할당해야 할 것입니다. 대신 **내부 리스트(intrusive list)**를 사용할 것입니다Obj 구조체 자체가 연결 리스트 노드가 됩니다. 각 Obj는 체인에서 다음 Obj를 가리키는 포인터를 갖습니다.

struct Obj {
  ObjType type;
object.h
in struct Obj
  struct Obj* next;
};
object.h, in struct Obj

VM은 리스트의 헤드(head)에 대한 포인터를 저장합니다.

  Value* stackTop;
vm.h
in struct VM
  Obj* objects;
} VM;
vm.h, in struct VM

VM을 처음 초기화할 때, 할당된 객체는 없습니다.

  resetStack();
vm.c
in initVM()
  vm.objects = NULL;
}
vm.c, in initVM()

Obj를 할당할 때마다 리스트에 삽입합니다.

  object->type = type;
object.c
in allocateObject()

  object->next = vm.objects;
  vm.objects = object;
  return object;
object.c, in allocateObject()

이것은 단일 연결 리스트이므로, 가장 쉬운 삽입 위치는 헤드(head)입니다. 이렇게 하면 테일(tail)에 대한 포인터를 따로 저장하고 업데이트할 필요가 없습니다.

“object” 모듈이 “vm” 모듈의 전역 `vm` 변수를 직접 사용하므로, 이를 외부로 노출해야 합니다.

} InterpretResult;

vm.h
add after enum InterpretResult
extern VM vm;

void initVM();
vm.h, add after enum InterpretResult

결국, 가비지 컬렉터는 VM이 실행되는 동안 메모리를 해제할 것입니다. 하지만 그 전에도 사용자의 프로그램이 완료될 때 메모리에 남아있는 사용되지 않는 객체들이 일반적으로 있을 것입니다. VM은 그러한 객체들도 해제해야 합니다.

그것에 대한 정교한 논리는 없습니다. 프로그램이 완료되면, 우리는 *모든* 객체를 해제할 수 있습니다. 지금 바로 이를 구현할 수 있으며, 또 그래야 합니다.

void freeVM() {
vm.c
in freeVM()
  freeObjects();
}
vm.c, in freeVM()

옛날에 정의했던 그 빈 함수가 드디어 뭔가 하는군요! 이 함수를 호출합니다.

void* reallocate(void* pointer, size_t oldSize, size_t newSize);
memory.h
add after reallocate()
void freeObjects();

#endif
memory.h, add after reallocate()

객체를 해제하는 방법은 다음과 같습니다.

memory.c
add after reallocate()
void freeObjects() {
  Obj* object = vm.objects;
  while (object != NULL) {
    Obj* next = object->next;
    freeObject(object);
    object = next;
  }
}
memory.c, add after reallocate()

이는 연결 리스트를 순회하고 노드를 해제하는 CS 101 교과서적인 구현입니다. 각 노드에 대해 다음을 호출합니다.

memory.c
add after reallocate()
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;
    }
  }
}
memory.c, add after reallocate()

우리는 Obj 자체만을 해제하는 것이 아닙니다. 일부 객체 타입은 자신이 소유하는 다른 메모리도 할당하기 때문에, 각 객체 타입의 특별한 필요를 처리하기 위한 약간의 타입별 코드가 필요합니다. 여기서는 문자 배열을 해제한 다음 ObjString을 해제하는 것을 의미합니다. 이 두 가지 모두 마지막 메모리 관리 매크로를 사용합니다.

    (type*)reallocate(NULL, 0, sizeof(type) * (count))
memory.h

#define FREE(type, pointer) reallocate(pointer, sizeof(type), 0)

#define GROW_CAPACITY(capacity) \
memory.h

이는 `reallocate()`를 감싸는 작은 래퍼로, 할당을 0바이트로 “재조정”합니다.

늘 그렇듯이, 모든 것을 연결하기 위해 인클루드가 필요합니다.

#include "common.h"
memory.h
#include "object.h"

#define ALLOCATE(type, count) \
memory.h

그 다음 구현 파일에서:

#include "memory.h"
memory.c
#include "vm.h"

void* reallocate(void* pointer, size_t oldSize, size_t newSize) {
memory.c

이로써 우리 VM은 더 이상 메모리 누수를 일으키지 않습니다. 좋은 C 프로그램처럼 종료하기 전에 깔끔하게 정리합니다. 하지만 VM이 실행되는 동안에는 어떤 객체도 해제하지 않습니다. 나중에 더 오래 실행되는 Lox 프로그램을 작성할 수 있게 되면, VM은 프로그램 전체가 완료될 때까지 단 한 바이트도 포기하지 않고 점점 더 많은 메모리를 차지할 것입니다.

진정한 가비지 컬렉터를 추가하기 전까지는 이 문제를 다루지 않을 것이지만, 이는 큰 진전입니다. 이제 다양한 종류의 동적으로 할당된 객체를 지원할 인프라를 갖추었습니다. 그리고 이를 사용하여 대부분의 프로그래밍 언어에서 가장 많이 사용되는 타입 중 하나인 문자열을 clox에 추가했습니다. 문자열은 특히 동적 언어에서 또 다른 근본적인 데이터 타입인 유서 깊은 해시 테이블을 구축할 수 있게 해줍니다. 하지만 그것은 다음 장에서 다루겠습니다 . . . 

도전 과제

  1. 각 문자열은 두 번의 개별적인 동적 할당을 필요로 합니다하나는 ObjString을 위한 것이고, 다른 하나는 문자 배열을 위한 것입니다. 값에서 문자에 접근하려면 두 번의 포인터 간접 참조가 필요하며, 이는 성능에 좋지 않을 수 있습니다. 더 효율적인 해결책은 **유연한 배열 멤버(flexible array members)**라는 기술에 의존합니다. 이를 사용하여 ObjString과 문자 배열을 단일 연속 할당에 저장하십시오.

  2. 각 문자열 리터럴에 대한 ObjString을 생성할 때, 문자들을 힙에 복사합니다. 그렇게 함으로써 나중에 문자열이 해제될 때, 문자들도 안전하게 해제할 수 있음을 알 수 있습니다.

    이것은 더 간단한 접근 방식이지만 일부 메모리를 낭비하며, 이는 매우 제한된 장치에서는 문제가 될 수 있습니다. 대신, 어떤 ObjString이 자신의 문자 배열을 소유하고 있는지, 그리고 어떤 ObjString이 단순히 원래 소스 문자열이나 다른 해제 불가능한 위치를 가리키는 “상수 문자열”인지 추적할 수 있습니다. 이를 지원하는 기능을 추가하십시오.

  3. 만약 Lox가 여러분의 언어였다면, 사용자가 하나의 문자열 피연산자와 다른 타입의 피연산자를 가지고 `+`를 사용하려고 할 때 어떻게 처리하게 할 것입니까? 여러분의 선택을 정당화하고, 다른 언어들은 어떻게 하는지 설명하십시오.

설계 노트: 문자열 인코딩

이 책에서 저는 실제 언어 구현에서 마주칠 복잡한 문제들을 피하지 않으려 노력합니다. 항상 가장 *정교한* 해결책을 사용하지는 않을 수 있지만결국 입문서이니까요문제가 전혀 존재하지 않는 척하는 것은 정직하지 않다고 생각합니다. 하지만 한 가지 정말 어려운 문제, 즉 문자열을 어떻게 표현할지에 대한 결정은 살짝 피해 갔습니다.

문자열 인코딩에는 두 가지 측면이 있습니다:

  • **문자열에서 단일 “문자”란 무엇인가?** 얼마나 많은 다른 값이 있으며 무엇을 나타내는가? 이에 대한 첫 번째 널리 채택된 표준 답변은 ASCII였습니다. 이는 127개의 다른 문자 값을 제공하고 그들이 무엇인지 명시했습니다. 이는 훌륭했습니다 . . . 만약 당신이 오직 영어에만 신경 썼다면 말이죠. 비록 “레코드 분리자(record separator)”나 “동기 유휴(synchronous idle)”와 같이 이상하고 대부분 잊혀진 문자들을 가지고 있지만, 움라우트, 악센트, 또는 그레이브 악센트는 단 하나도 없습니다. “jalapeño”, “naïve”, “Gruyère” 또는 “Mötley Crüe”를 표현할 수 없습니다.

    다음으로 유니코드(Unicode)가 등장했습니다. 처음에는 16,384개의 다른 문자(**코드 포인트**)를 지원했으며, 이는 16비트에 약간의 여유 비트를 두고 잘 맞았습니다. 나중에 그 수가 계속 증가하여, 이제는 💩 (유니코드 문자 ‘PILE OF POO’, `U+1F4A9`)와 같은 인간 의사소통의 필수적인 도구를 포함하여 10만 개가 넘는 다른 코드 포인트가 있습니다.

    그 긴 코드 포인트 목록만으로는 언어가 지원할 수 있는 모든 가능한 가시적 글리프(glyph)를 나타내기에 충분하지 않습니다. 이를 처리하기 위해 유니코드에는 선행 코드 포인트를 수정하는 **결합 문자(combining characters)**도 있습니다. 예를 들어, “a” 뒤에 결합 문자 “¨”가 오면 “ä”가 됩니다. (더 혼란스럽게도 유니코드에는 “ä”처럼 보이는 단일 코드 포인트도 있습니다.)

    사용자가 “naïve”의 네 번째 “문자”에 접근할 때, “v”를 기대할까요 아니면 “¨”를 기대할까요? 전자는 각 코드 포인트와 그 결합 문자를 단일 단위유니코드에서 **확장된 그래핌 클러스터(extended grapheme cluster)**라고 부르는 것으로 생각하고 있음을 의미하고, 후자는 개별 코드 포인트로 생각하고 있음을 의미합니다. 여러분의 사용자들은 무엇을 기대할까요?

  • **단일 단위는 메모리에서 어떻게 표현되는가?** ASCII를 사용하는 대부분의 시스템은 각 문자에 단일 바이트를 부여하고 상위 비트는 사용하지 않았습니다. 유니코드에는 몇 가지 일반적인 인코딩이 있습니다. UTF-16은 대부분의 코드 포인트를 16비트에 담습니다. 모든 코드 포인트가 그 크기에 맞았을 때는 훌륭했습니다. 그것이 오버플로우되었을 때, 단일 코드 포인트를 표현하기 위해 여러 16비트 코드 단위를 사용하는 *대리쌍(surrogate pairs)*을 추가했습니다. UTF-32는 UTF-16의 다음 진화입니다모든 코드 포인트에 완전한 32비트를 부여합니다.

    UTF-8은 이들 중 어느 것보다 복잡합니다. 코드 포인트를 인코딩하는 데 가변적인 수의 바이트를 사용합니다. 값이 낮은 코드 포인트는 더 적은 바이트에 들어갑니다. 각 문자가 다른 수의 바이트를 차지할 수 있으므로, 특정 코드 포인트를 찾기 위해 문자열에 직접 인덱싱할 수 없습니다. 예를 들어, 10번째 코드 포인트를 원한다면, 선행하는 모든 코드 포인트를 순회하고 디코딩하지 않고는 문자열에서 몇 바이트 떨어져 있는지 알 수 없습니다.

문자 표현 및 인코딩을 선택하는 것은 근본적인 절충안을 수반합니다. 공학의 많은 것들처럼, 완벽한 해결책은 없습니다:

  • ASCII는 메모리 효율적이고 빠르지만, 비라틴어권 언어는 배제합니다.

  • UTF-32는 빠르고 전체 유니코드 범위를 지원하지만, 대부분의 코드 포인트가 32비트 전체가 필요 없는 낮은 값 범위에 속하는 경향이 있다는 점을 고려할 때 많은 메모리를 낭비합니다.

  • UTF-8은 메모리 효율적이고 전체 유니코드 범위를 지원하지만, 가변 길이 인코딩으로 인해 임의의 코드 포인트에 접근하는 것이 느립니다.

  • UTF-16은 이들 모두보다 좋지 않습니다유니코드가 이전 16비트 범위를 벗어나면서 생긴 추악한 결과입니다. UTF-8보다 메모리 효율성이 떨어지지만, 대리쌍 덕분에 여전히 가변 길이 인코딩입니다. 가능하다면 피하십시오. 안타깝게도, 여러분의 언어가 브라우저, JVM, 또는 CLR에서 실행되거나 이들과 상호 운용되어야 한다면, 이들 모두 문자열에 UTF-16을 사용하므로 기본 시스템에 문자열을 전달할 때마다 변환해야 하는 번거로움을 피하기 위해 UTF-16을 고수해야 할 수도 있습니다.

한 가지 옵션은 최대 접근 방식을 취하여 가장 “올바른” 것을 하는 것입니다. 모든 유니코드 코드 포인트를 지원하십시오. 내부적으로는 문자열 내용에 따라 각 문자열에 대한 인코딩을 선택하십시오모든 코드 포인트가 1바이트에 맞으면 ASCII를 사용하고, 대리쌍이 없으면 UTF-16을 사용하는 식입니다. 사용자에게 코드 포인트와 확장된 그래핌 클러스터 모두를 순회할 수 있는 API를 제공하십시오.

이것은 모든 기반을 다루지만, 정말 복잡합니다. 구현하고, 디버그하고, 최적화하는 데 많은 노력이 필요합니다. 문자열을 직렬화하거나 다른 시스템과 상호 운용할 때, 모든 인코딩을 처리해야 합니다. 사용자들은 두 가지 인덱싱 API를 이해하고 언제 어떤 것을 사용해야 할지 알아야 합니다. 이는 Raku나 Swift와 같은 최신 대규모 언어들이 취하는 경향이 있는 접근 방식입니다.

더 간단한 절충안은 항상 UTF-8을 사용하여 인코딩하고, 코드 포인트와 작동하는 API만 노출하는 것입니다. 그래핌 클러스터와 작업하기를 원하는 사용자에게는 이를 위한 타사 라이브러리를 사용하도록 하십시오. 이는 ASCII보다 라틴어 중심적이지 않으면서도 크게 더 복잡하지 않습니다. 코드 포인트별 빠른 직접 인덱싱 기능은 잃지만, 일반적으로 그 없이도 살 수 있거나 O(1) 대신 O(n)으로 만드는 것을 감당할 수 있습니다.

만약 제가 대규모 애플리케이션을 작성하는 사람들을 위한 주요 언어를 설계한다면, 아마도 최대 접근 방식을 택할 것입니다. 제 작은 임베디드 스크립트 언어인 Wren의 경우, UTF-8과 코드 포인트를 선택했습니다.