27

클래스와 인스턴스

사물에 너무 많은 관심을 기울이면 파멸할 수 있습니다. 하지만 어떤 것을 충분히 소중히 여기면, 그것은 스스로 생명을 얻지 않나요? 아름다운 사물들의 진정한 의미는, 더 큰 아름다움과 우리를 연결해 주는 것이 아닌가요?

도나 타트, 『황금방울새』

clox에서 구현해야 할 마지막 영역은 객체 지향 프로그래밍(OOP)입니다. OOP는 클래스, 인스턴스, 필드, 메서드, 초기자, 상속 등 서로 얽힌 기능들의 묶음입니다. 비교적 고수준 언어인 자바를 사용했을 때는 이 모든 것을 두 장에 담을 수 있었습니다. 하지만 이제 이쑤시개로 에펠탑 모형을 만드는 것 같은 느낌의 C 언어로 코딩하고 있으니, 같은 내용을 세 장에 걸쳐 다룰 것입니다. 덕분에 구현 과정을 여유롭게 살펴보게 될 것입니다. 클로저가비지 컬렉터처럼 힘든 장들을 거쳐왔으니, 이제 쉴 자격이 충분합니다. 사실 이 장부터는 책의 내용이 쉬워질 겁니다.

이 장에서는 클래스, 인스턴스, 필드의 세 가지 기능을 다룹니다. 이는 객체 지향의 상태(stateful) 측면입니다. 이어지는 두 장에서는 이 객체들에 동작과 코드 재사용 기능을 부여할 것입니다.

27 . 1클래스 객체

클래스 기반 객체 지향 언어에서 모든 것은 클래스로 시작합니다. 클래스는 프로그램에 어떤 종류의 객체가 존재하는지를 정의하며, 새로운 인스턴스를 생성하는 팩토리 역할을 합니다. 바텀업(bottom-up) 방식으로, 런타임 표현부터 시작하여 이를 언어에 연결할 것입니다.

이제 VM에 새로운 객체 타입을 추가하는 과정에 익숙하실 것입니다. 먼저 구조체(struct)부터 시작합니다.

} ObjClosure;
object.h
ObjClosure 구조체 뒤에 추가

typedef struct {
  Obj obj;
  ObjString* name;
} ObjClass;

ObjClosure* newClosure(ObjFunction* function);
object.h, ObjClosure 구조체 뒤에 추가

Obj 헤더 뒤에는 클래스의 이름을 저장합니다. 이는 사용자 프로그램에 엄밀히 필요한 것은 아니지만, 스택 트레이스(stack trace)와 같은 상황에서 런타임에 이름을 표시할 수 있게 해줍니다.

새로운 타입에는 ObjType 열거형에 해당하는 케이스가 필요합니다.

typedef enum {
object.h
ObjType 열거형 내
  OBJ_CLASS,
  OBJ_CLOSURE,
object.h, ObjType 열거형 내

그리고 해당 타입에 해당하는 매크로 쌍이 추가됩니다. 먼저, 객체의 타입을 테스트하기 위한 것입니다.

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

object.h
#define IS_CLASS(value)        isObjType(value, OBJ_CLASS)
#define IS_CLOSURE(value)      isObjType(value, OBJ_CLOSURE)
object.h

그리고 Value를 ObjClass 포인터로 캐스팅하기 위한 것입니다.

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

object.h
#define AS_CLASS(value)        ((ObjClass*)AS_OBJ(value))
#define AS_CLOSURE(value)      ((ObjClosure*)AS_OBJ(value))
object.h

VM은 이 함수를 사용하여 새로운 클래스 객체를 생성합니다.

} ObjClass;

object.h
ObjClass 구조체 뒤에 추가
ObjClass* newClass(ObjString* name);
ObjClosure* newClosure(ObjFunction* function);
object.h, ObjClass 구조체 뒤에 추가

구현은 여기에 있습니다.

object.c
allocateObject() 뒤에 추가
ObjClass* newClass(ObjString* name) {
  ObjClass* klass = ALLOCATE_OBJ(ObjClass, OBJ_CLASS);
  klass->name = name; 
  return klass;
}
object.c, allocateObject() 뒤에 추가

거의 모든 것이 상용구 코드입니다. 클래스 이름을 문자열로 받아 저장합니다. 사용자가 새로운 클래스를 선언할 때마다, VM은 해당 클래스를 나타내기 위해 새로운 ObjClass 구조체를 생성할 것입니다.

VM이 더 이상 클래스를 필요로 하지 않을 때, 다음과 같이 해제합니다.

  switch (object->type) {
memory.c
freeObject() 내
    case OBJ_CLASS: {
      FREE(ObjClass, object);
      break;
    } 
    case OBJ_CLOSURE: {
memory.c, freeObject() 내

이제 메모리 관리자가 있으므로, 클래스 객체들을 추적하는 기능도 지원해야 합니다.

  switch (object->type) {
memory.c
blackenObject() 내
    case OBJ_CLASS: {
      ObjClass* klass = (ObjClass*)object;
      markObject((Obj*)klass->name);
      break;
    }
    case OBJ_CLOSURE: {
memory.c, blackenObject() 내

가비지 컬렉터가 클래스 객체에 도달하면, 해당 클래스의 이름을 표시하여 해당 문자열도 계속 살아있게 유지합니다.

VM이 클래스에 대해 수행할 수 있는 마지막 작업은 클래스를 출력하는 것입니다.

  switch (OBJ_TYPE(value)) {
object.c
printObject() 내
    case OBJ_CLASS:
      printf("%s", AS_CLASS(value)->name->chars);
      break;
    case OBJ_CLOSURE:
object.c, printObject() 내

클래스는 단순히 자신의 이름을 출력합니다.

27 . 2클래스 선언

런타임 표현을 준비했으니, 이제 언어에 클래스 지원을 추가할 준비가 되었습니다. 다음으로 파서(parser)로 이동합니다.

static void declaration() {
compiler.c
declaration() 내
1줄 교체
  if (match(TOKEN_CLASS)) {
    classDeclaration();
  } else if (match(TOKEN_FUN)) {
    funDeclaration();
compiler.c, declaration() 내, 1줄 교체

클래스 선언은 문(statement)이며, 파서는 선행하는 class 키워드를 통해 이를 인식합니다. 나머지 컴파일은 여기에서 이루어집니다.

compiler.c
function() 뒤에 추가
static void classDeclaration() {
  consume(TOKEN_IDENTIFIER, "Expect class name.");
  uint8_t nameConstant = identifierConstant(&parser.previous);
  declareVariable();

  emitBytes(OP_CLASS, nameConstant);
  defineVariable(nameConstant);

  consume(TOKEN_LEFT_BRACE, "Expect '{' before class body.");
  consume(TOKEN_RIGHT_BRACE, "Expect '}' after class body.");
}
compiler.c, function() 뒤에 추가

class 키워드 바로 뒤에는 클래스 이름이 옵니다. 이 식별자를 가져와 둘러싸는 함수의 상수 테이블에 문자열로 추가합니다. 방금 보셨듯이, 클래스를 출력하면 그 이름이 표시되므로 컴파일러는 이 이름 문자열을 런타임이 찾을 수 있는 어딘가에 저장해야 합니다. 상수 테이블이 바로 그 방법입니다.

클래스의 이름은 클래스 객체를 같은 이름의 변수에 바인딩하는 데도 사용됩니다. 따라서 토큰을 소비한 직후 해당 식별자로 변수를 선언합니다.

다음으로, 런타임에 실제 클래스 객체를 생성하기 위한 새로운 명령어를 내보냅니다. 이 명령어는 클래스 이름의 상수 테이블 인덱스를 피연산자로 받습니다.

그 후, 클래스 본문을 컴파일하기 전에 클래스 이름에 대한 변수를 정의합니다. 변수를 선언하는 것은 변수를 스코프에 추가하는 것이지만, 이전 장에서 변수가 정의되기 전까지는 사용할 수 없다고 언급했습니다. 클래스의 경우, 본문 전에 변수를 정의합니다. 이렇게 하면 사용자가 자체 메서드 본문 내에서 포함하는 클래스를 참조할 수 있습니다. 이는 클래스의 새 인스턴스를 생성하는 팩토리 메서드와 같은 경우에 유용합니다.

마지막으로 본문을 컴파일합니다. 아직 메서드는 없으므로, 지금은 단순히 빈 중괄호 쌍입니다. Lox는 클래스 내부에 필드를 선언할 것을 요구하지 않으므로, 당분간 본문(및 파서) 작업은 끝입니다.

컴파일러가 새로운 명령어를 내보내고 있으니, 이제 이를 정의해 봅시다.

  OP_RETURN,
chunk.h
OpCode 열거형 내
  OP_CLASS,
} OpCode;
chunk.h, OpCode 열거형 내

그리고 역어셈블러(disassembler)에 추가합니다.

    case OP_RETURN:
      return simpleInstruction("OP_RETURN", offset);
debug.c
disassembleInstruction() 내
    case OP_CLASS:
      return constantInstruction("OP_CLASS", chunk, offset);
    default:
debug.c, disassembleInstruction() 내

이렇게 거대해 보이는 기능 치고는 인터프리터 지원이 최소한입니다.

        break;
      }
vm.c
run() 내
      case OP_CLASS:
        push(OBJ_VAL(newClass(READ_STRING())));
        break;
    }
vm.c, run() 내

우리는 상수 테이블에서 클래스 이름에 해당하는 문자열을 로드하고 이를 newClass()에 전달합니다. 그러면 주어진 이름으로 새로운 클래스 객체가 생성됩니다. 이를 스택에 푸시하면 됩니다. 클래스가 전역 변수에 바인딩되는 경우, 컴파일러의 defineVariable() 호출은 해당 객체를 스택에서 전역 변수 테이블에 저장하는 코드를 내보낼 것입니다. 그렇지 않은 경우, 새로운 지역 변수를 위해 스택에 있어야 할 위치에 정확히 놓이게 됩니다.

이제 VM이 클래스를 지원하게 되었습니다. 다음 코드를 실행할 수 있습니다.

class Brioche {}
print Brioche;

아쉽게도, 클래스로 할 수 있는 것이라고는 출력뿐입니다. 따라서 다음은 클래스를 더 유용하게 만드는 것입니다.

27 . 3클래스의 인스턴스

언어에서 클래스는 두 가지 주요 목적을 가집니다.

메서드는 다음 장에서 다룰 것이므로, 지금은 첫 번째 부분에 대해서만 다룰 것입니다. 클래스가 인스턴스를 생성하기 전에, 인스턴스에 대한 표현이 필요합니다.

} ObjClass;
object.h
ObjClass 구조체 뒤에 추가

typedef struct {
  Obj obj;
  ObjClass* klass;
  Table fields; 
} ObjInstance;

ObjClass* newClass(ObjString* name);
object.h, ObjClass 구조체 뒤에 추가

인스턴스는 자신의 클래스를 압니다. 각 인스턴스는 자신이 속한 클래스에 대한 포인터를 가지고 있습니다. 이 장에서는 이것을 많이 사용하지 않겠지만, 메서드를 추가할 때 매우 중요해질 것입니다.

이 장에서 더 중요한 것은 인스턴스가 자신의 상태를 저장하는 방식입니다. Lox는 사용자가 런타임에 인스턴스에 자유롭게 필드를 추가할 수 있도록 합니다. 이는 성장할 수 있는 저장 메커니즘이 필요하다는 것을 의미합니다. 동적 배열을 사용할 수도 있지만, 이름으로 필드를 가능한 한 빠르게 조회하고 싶습니다. 이름으로 값 집합에 빠르게 접근하기에 완벽한 데이터 구조가 있으며, 더 편리하게도 이미 구현해 두었습니다. 각 인스턴스는 해시 테이블을 사용하여 필드를 저장합니다.

include 문만 추가하면 됩니다.

#include "chunk.h"
object.h
#include "table.h"
#include "value.h"
object.h

이 새로운 구조체는 새로운 객체 타입을 얻습니다.

  OBJ_FUNCTION,
object.h
ObjType 열거형 내
  OBJ_INSTANCE,
  OBJ_NATIVE,
object.h, ObjType 열거형 내

잠시 속도를 늦추고 싶습니다. Lox 언어의 "타입" 개념과 VM 구현의 "타입" 개념이 혼란스러울 수 있는 방식으로 서로 겹치기 때문입니다. clox를 만드는 C 코드 내부에는 Obj—ObjString, ObjClosure 등—의 여러 다른 타입이 있습니다. 각각은 고유한 내부 표현과 의미론을 가집니다.

Lox 언어에서 사용자는 자신만의 클래스(예: Cake와 Pie)를 정의하고 해당 클래스의 인스턴스를 생성할 수 있습니다. 사용자 관점에서는 Cake 인스턴스가 Pie 인스턴스와 다른 타입의 객체입니다. 하지만 VM 관점에서는 사용자가 정의하는 모든 클래스는 단순히 ObjClass 타입의 또 다른 값일 뿐입니다. 마찬가지로, 사용자 프로그램의 각 인스턴스는 어떤 클래스의 인스턴스든 상관없이 ObjInstance입니다. 이 하나의 VM 객체 타입이 모든 클래스의 인스턴스를 포괄합니다. 이 두 세계는 대략 다음과 같이 매핑됩니다.

A set of class declarations and instances, and the runtime representations each maps to.

이해되셨죠? 좋아요, 다시 구현으로 돌아갑시다. 평소처럼 매크로도 추가합니다.

#define IS_FUNCTION(value)     isObjType(value, OBJ_FUNCTION)
object.h
#define IS_INSTANCE(value)     isObjType(value, OBJ_INSTANCE)
#define IS_NATIVE(value)       isObjType(value, OBJ_NATIVE)
object.h

그리고:

#define AS_FUNCTION(value)     ((ObjFunction*)AS_OBJ(value))
object.h
#define AS_INSTANCE(value)     ((ObjInstance*)AS_OBJ(value))
#define AS_NATIVE(value) \
object.h

인스턴스가 생성된 후에 필드가 추가되므로, "생성자" 함수는 클래스만 알면 됩니다.

ObjFunction* newFunction();
object.h
newFunction() 뒤에 추가
ObjInstance* newInstance(ObjClass* klass);
ObjNative* newNative(NativeFn function);
object.h, newFunction() 뒤에 추가

이 함수는 여기에서 구현합니다.

object.c
newFunction() 뒤에 추가
ObjInstance* newInstance(ObjClass* klass) {
  ObjInstance* instance = ALLOCATE_OBJ(ObjInstance, OBJ_INSTANCE);
  instance->klass = klass;
  initTable(&instance->fields);
  return instance;
}
object.c, newFunction() 뒤에 추가

인스턴스의 클래스에 대한 참조를 저장합니다. 그런 다음 필드 테이블을 빈 해시 테이블로 초기화합니다. 새로운 아기 객체가 탄생했습니다!

인스턴스의 수명 중 슬픈 마지막에는 해제됩니다.

      FREE(ObjFunction, object);
      break;
    }
memory.c
freeObject() 내
    case OBJ_INSTANCE: {
      ObjInstance* instance = (ObjInstance*)object;
      freeTable(&instance->fields);
      FREE(ObjInstance, object);
      break;
    }
    case OBJ_NATIVE:
memory.c, freeObject() 내

인스턴스는 자신의 필드 테이블을 소유하므로 인스턴스를 해제할 때 테이블도 해제합니다. 테이블 내의 엔트리들을 명시적으로 해제하지는 않습니다. 왜냐하면 해당 객체들에 대한 다른 참조가 있을 수 있기 때문입니다. 가비지 컬렉터가 이를 처리해 줄 것입니다. 여기서는 테이블 자체의 엔트리 배열만 해제합니다.

가비지 컬렉터 말인데요, 인스턴스를 통해 추적하는 기능이 필요합니다.

      markArray(&function->chunk.constants);
      break;
    }
memory.c
blackenObject() 내
    case OBJ_INSTANCE: {
      ObjInstance* instance = (ObjInstance*)object;
      markObject((Obj*)instance->klass);
      markTable(&instance->fields);
      break;
    }
    case OBJ_UPVALUE:
memory.c, blackenObject() 내

인스턴스가 살아있다면, 그 클래스도 유지해야 합니다. 또한, 인스턴스의 필드에 의해 참조되는 모든 객체도 유지해야 합니다. 루트가 아닌 대부분의 살아있는 객체는 어떤 인스턴스가 필드에서 해당 객체를 참조하고 있기 때문에 도달 가능합니다. 다행히도, 우리는 이미 훌륭한 markTable() 함수를 가지고 있어 이들을 쉽게 추적할 수 있습니다.

덜 중요하지만 여전히 중요한 것은 출력입니다.

      break;
object.c
printObject() 내
    case OBJ_INSTANCE:
      printf("%s instance",
             AS_INSTANCE(value)->klass->name->chars);
      break;
    case OBJ_NATIVE:
object.c, printObject() 내

인스턴스는 자신의 이름 뒤에 "instance"를 출력합니다. ("instance" 부분은 주로 클래스와 인스턴스가 동일하게 출력되지 않도록 하기 위함입니다.)

진정한 재미는 인터프리터에서 일어납니다. Lox에는 특별한 new 키워드가 없습니다. 클래스의 인스턴스를 생성하는 방법은 클래스 자체를 함수처럼 호출하는 것입니다. 런타임은 이미 함수 호출을 지원하며, 사용자가 숫자나 다른 유효하지 않은 타입을 호출하려고 하지 않도록 호출되는 객체의 타입을 확인합니다.

우리는 새로운 케이스로 런타임 검사를 확장합니다.

    switch (OBJ_TYPE(callee)) {
vm.c
callValue() 내
      case OBJ_CLASS: {
        ObjClass* klass = AS_CLASS(callee);
        vm.stackTop[-argCount - 1] = OBJ_VAL(newInstance(klass));
        return true;
      }
      case OBJ_CLOSURE:
vm.c, callValue() 내

호출되는 값(즉, 여는 괄호 왼쪽의 표현식을 평가할 때 생성되는 객체)이 클래스인 경우, 우리는 이를 생성자 호출로 간주합니다. 호출된 클래스의 새 인스턴스를 생성하고 그 결과를 스택에 저장합니다.

한 걸음 더 나아갔습니다. 이제 클래스를 정의하고 그 인스턴스를 생성할 수 있습니다.

class Brioche {}
print Brioche();

이제 두 번째 줄의 Brioche 뒤에 괄호가 붙어 있는 것을 주목하세요. 이는 "Brioche instance"를 출력합니다.

27 . 4가져오기 및 설정 표현식

인스턴스를 위한 우리의 객체 표현은 이미 상태를 저장할 수 있으므로, 남은 것은 그 기능을 사용자에게 노출하는 것뿐입니다. 필드는 가져오기(get) 및 설정(set) 표현식을 사용하여 접근하고 수정합니다. 전통을 깨지 않고 Lox는 고전적인 "점(dot)" 구문을 사용합니다.

eclair.filling = "pastry creme";
print eclair.filling;

마침표(영국 친구들을 위한 풀 스톱)—는 일종의 중위 연산자처럼 작동합니다. 왼쪽에 먼저 평가되어 인스턴스를 생성하는 표현식이 있습니다. 그 다음에는 .이 오고 이어서 필드 이름이 나옵니다. 선행하는 피연산자가 있으므로, 이를 중위 표현식으로 파스 테이블에 연결합니다.

  [TOKEN_COMMA]         = {NULL,     NULL,   PREC_NONE},
compiler.c
1줄 교체
  [TOKEN_DOT]           = {NULL,     dot,    PREC_CALL},
  [TOKEN_MINUS]         = {unary,    binary, PREC_TERM},
compiler.c, 1줄 교체

다른 언어들처럼, . 연산자는 함수 호출의 괄호만큼 높은 우선순위로 강하게 결합합니다. 파서가 점 토큰을 소비한 후, 새로운 파스 함수로 디스패치합니다.

compiler.c
call() 뒤에 추가
static void dot(bool canAssign) {
  consume(TOKEN_IDENTIFIER, "Expect property name after '.'.");
  uint8_t name = identifierConstant(&parser.previous);

  if (canAssign && match(TOKEN_EQUAL)) {
    expression();
    emitBytes(OP_SET_PROPERTY, name);
  } else {
    emitBytes(OP_GET_PROPERTY, name);
  }
}
compiler.c, call() 뒤에 추가

파서는 점 바로 뒤에 프로퍼티 이름을 찾을 것으로 예상합니다. 해당 토큰의 렉심을 상수 테이블에 문자열로 로드하여 런타임에 이름을 사용할 수 있도록 합니다.

이 한 함수가 두 가지 새로운 표현식 형태인 게터와 세터를 처리합니다. 필드 이름 뒤에 등호(=)가 보이면, 이는 필드에 할당하는 설정 표현식이어야 합니다. 하지만 필드 뒤에 등호가 오는 것을 항상 컴파일하도록 허용하지는 않습니다. 다음을 생각해 보세요.

a + b.c = 3

이는 Lox 문법에 따르면 구문적으로 유효하지 않으며, 이는 우리의 Lox 구현이 이 오류를 감지하고 보고해야 할 의무가 있다는 것을 의미합니다. 만약 dot()이 `= 3` 부분을 조용히 파싱했다면, 우리는 사용자가 다음과 같이 작성한 것처럼 코드를 잘못 해석했을 것입니다.

a + (b.c = 3)

문제는 설정 표현식의 `=` 부분이 `.` 부분보다 훨씬 낮은 우선순위를 가진다는 것입니다. 파서는 세터가 나타나는 것을 허용하기에는 너무 높은 우선순위의 컨텍스트에서 dot()을 호출할 수 있습니다. 이를 잘못 허용하는 것을 피하기 위해, 우리는 canAssign이 참일 때만 등호 부분을 파싱하고 컴파일합니다. canAssign이 거짓일 때 등호 토큰이 나타나면, dot()은 이를 그대로 두고 반환합니다. 이 경우, 컴파일러는 결국 parsePrecedence()로 되감겨져, 다음 토큰으로 남아있는 예상치 못한 `=`에서 멈추고 오류를 보고할 것입니다.

허용되는 컨텍스트에서 `=`를 찾으면, 그 뒤에 오는 표현식을 컴파일합니다. 그 후, 새로운 OP_SET_PROPERTY 명령어를 내보냅니다. 이 명령어는 상수 테이블에서 프로퍼티 이름의 인덱스에 대한 단일 피연산자를 받습니다. 설정 표현식을 컴파일하지 않았다면, 게터라고 가정하고 프로퍼티 이름에 대한 피연산자도 받는 OP_GET_PROPERTY 명령어를 내보냅니다.

이제 이 두 가지 새로운 명령어를 정의할 때입니다.

  OP_SET_UPVALUE,
chunk.h
OpCode 열거형 내
  OP_GET_PROPERTY,
  OP_SET_PROPERTY,
  OP_EQUAL,
chunk.h, OpCode 열거형 내

그리고 이들을 역어셈블하는 지원을 추가합니다.

      return byteInstruction("OP_SET_UPVALUE", chunk, offset);
debug.c
disassembleInstruction() 내
    case OP_GET_PROPERTY:
      return constantInstruction("OP_GET_PROPERTY", chunk, offset);
    case OP_SET_PROPERTY:
      return constantInstruction("OP_SET_PROPERTY", chunk, offset);
    case OP_EQUAL:
debug.c, disassembleInstruction() 내

27 . 4 . 1게터 및 세터 표현식 해석하기

런타임으로 넘어가서, 좀 더 간단한 가져오기 표현식부터 시작하겠습니다.

      }
vm.c
run() 내
      case OP_GET_PROPERTY: {
        ObjInstance* instance = AS_INSTANCE(peek(0));
        ObjString* name = READ_STRING();

        Value value;
        if (tableGet(&instance->fields, name, &value)) {
          pop(); // Instance.
          push(value);
          break;
        }
      }
      case OP_EQUAL: {
vm.c, run() 내

인터프리터가 이 명령어에 도달하면, 점(.) 왼쪽의 표현식은 이미 실행되었고 결과 인스턴스는 스택의 최상단에 있습니다. 우리는 상수 풀에서 필드 이름을 읽어 인스턴스의 필드 테이블에서 조회합니다. 해시 테이블에 해당 이름으로 엔트리가 포함되어 있다면, 인스턴스를 팝(pop)하고 엔트리의 값을 결과로 푸시(push)합니다.

물론, 필드가 존재하지 않을 수도 있습니다. Lox에서는 이를 런타임 오류로 정의했습니다. 따라서 이를 확인하는 코드를 추가하고 발생하면 중단합니다.

          push(value);
          break;
        }
vm.c
run() 내
        runtimeError("Undefined property '%s'.", name->chars);
        return INTERPRET_RUNTIME_ERROR;
      }
      case OP_EQUAL: {
vm.c, run() 내

여러분이 아마도 눈치챘을 또 다른 처리해야 할 실패 모드가 있습니다. 위의 코드는 점(.) 왼쪽의 표현식이 ObjInstance로 평가되었다고 가정합니다. 하지만 사용자가 다음과 같이 작성하는 것을 막을 수는 없습니다.

var obj = "not an instance";
print obj.field;

사용자 프로그램은 잘못되었지만, VM은 여전히 어느 정도 우아하게 처리해야 합니다. 지금 상태로는 ObjString의 비트들을 ObjInstance로 잘못 해석하여, 글쎄요, 불이 나거나 분명히 우아하지 않은 어떤 일이 일어날 것입니다.

Lox에서는 인스턴스만이 필드를 가질 수 있습니다. 문자열이나 숫자에 필드를 추가할 수는 없습니다. 따라서 값을 인스턴스에서 필드에 접근하기 전에 해당 값이 인스턴스인지 확인해야 합니다.

      case OP_GET_PROPERTY: {
vm.c
run() 내
        if (!IS_INSTANCE(peek(0))) {
          runtimeError("Only instances have properties.");
          return INTERPRET_RUNTIME_ERROR;
        }

        ObjInstance* instance = AS_INSTANCE(peek(0));
vm.c, run() 내

스택의 값이 인스턴스가 아니라면, 런타임 오류를 보고하고 안전하게 종료합니다.

물론, 어떤 인스턴스도 필드를 가지고 있지 않다면 가져오기 표현식은 그다지 유용하지 않습니다. 이를 위해서는 세터가 필요합니다.

        return INTERPRET_RUNTIME_ERROR;
      }
vm.c
run() 내
      case OP_SET_PROPERTY: {
        ObjInstance* instance = AS_INSTANCE(peek(1));
        tableSet(&instance->fields, READ_STRING(), peek(0));
        Value value = pop();
        pop();
        push(value);
        break;
      }
      case OP_EQUAL: {
vm.c, run() 내

이 코드는 OP_GET_PROPERTY보다 약간 더 복잡합니다. 이 명령어가 실행될 때, 스택의 최상단에는 필드를 설정할 인스턴스가 있고 그 위에는 저장할 값이 있습니다. 이전과 마찬가지로, 명령어의 피연산자를 읽어 필드 이름 문자열을 찾습니다. 이를 사용하여 스택 최상단의 값을 인스턴스의 필드 테이블에 저장합니다.

그 다음은 약간의 스택 조작이 있습니다. 저장된 값을 팝(pop)하고, 인스턴스를 팝하고, 마지막으로 값을 다시 푸시합니다. 다시 말해, 스택의 두 번째 요소를 제거하면서 최상단은 그대로 둡니다. 세터 자체는 할당된 값이 결과가 되는 표현식이므로, 해당 값을 스택에 남겨두어야 합니다. 제가 무슨 뜻인지 설명하겠습니다.

class Toast {}
var toast = Toast();
print toast.jam = "grape"; // "grape" 출력.

필드를 읽을 때와는 달리, 해시 테이블에 필드가 포함되어 있지 않은 경우를 걱정할 필요가 없습니다. 세터는 필요하면 필드를 암시적으로 생성합니다. 하지만 사용자가 인스턴스가 아닌 값에 필드를 저장하려고 잘못 시도하는 경우는 처리해야 합니다.

      case OP_SET_PROPERTY: {
vm.c
run() 내
        if (!IS_INSTANCE(peek(1))) {
          runtimeError("Only instances have fields.");
          return INTERPRET_RUNTIME_ERROR;
        }

        ObjInstance* instance = AS_INSTANCE(peek(1));
vm.c, run() 내

가져오기 표현식과 마찬가지로, 값의 타입을 확인하고 유효하지 않으면 런타임 오류를 보고합니다. 이것으로 Lox의 객체 지향 프로그래밍 지원 중 상태(stateful) 측면이 모두 갖춰졌습니다. 한번 시도해 보세요.

class Pair {}

var pair = Pair();
pair.first = 1;
pair.second = 2;
print pair.first + pair.second; // 3.

이건 객체 지향적이라는 느낌이 별로 들지 않습니다. 객체가 느슨한 구조체 같은 데이터 묶음인 이상하고 동적 타입의 C 변형에 가깝습니다. 일종의 동적 절차적 언어랄까요. 하지만 이는 표현력 면에서 큰 진전입니다. 우리의 Lox 구현은 이제 사용자가 데이터를 더 큰 단위로 자유롭게 집계할 수 있게 합니다. 다음 장에서는 이 불활성 데이터 덩어리들에 생명을 불어넣을 것입니다.

도전 과제

  1. 존재하지 않는 객체 필드에 접근하려고 하면 VM 전체가 즉시 중단됩니다. 사용자는 이 런타임 오류로부터 복구할 방법이 없으며, 필드에 접근하기 전에 필드가 존재하는지 확인할 방법도 없습니다. 오직 유효한 필드만 읽히도록 보장하는 것은 전적으로 사용자의 몫입니다.

    다른 동적 타입 언어들은 존재하지 않는 필드를 어떻게 처리할까요? Lox는 어떻게 해야 한다고 생각하시나요? 여러분의 해결책을 구현해 보세요.

  2. 필드는 런타임에 문자열 이름으로 접근됩니다. 하지만 그 이름은 항상 소스 코드에 식별자 토큰으로 직접 나타나야 합니다. 사용자 프로그램은 명령적으로 문자열 값을 생성한 다음 그것을 필드 이름으로 사용할 수 없습니다. 그렇게 할 수 있어야 한다고 생각하시나요? 이를 가능하게 하는 언어 기능을 고안하고 구현해 보세요.

  3. 반대로, Lox는 인스턴스에서 필드를 제거할 방법이 없습니다. 필드 값을 nil로 설정할 수는 있지만, 해시 테이블의 엔트리는 여전히 존재합니다. 다른 언어들은 이것을 어떻게 처리할까요? Lox에 대한 전략을 선택하고 구현해 보세요.

  4. 필드가 런타임에 이름으로 접근되기 때문에, 인스턴스 상태를 다루는 것은 느립니다. 기술적으로는 상수 시간 연산(해시 테이블 덕분에)이지만, 상수 계수가 상대적으로 큽니다. 이것이 동적 언어가 정적 타입 언어보다 느린 주된 이유 중 하나입니다.

    동적 타입 언어의 정교한 구현들은 이것을 어떻게 대처하고 최적화할까요?