메서드와 초기화자
춤판에 나섰을 때는 춤을 추는 것 외에는 할 일이 없다.
움베르토 에코, 로아나 여왕의 신비한 불꽃
이제 우리 가상 머신이 새로 생성된 객체들에 동작을 부여하여 생명을 불어넣을 때입니다. 즉, 메서드와 메서드 호출을 구현해야 합니다. 그리고 메서드의 특별한 종류인 초기화자(initializer)도 마찬가지입니다.
이 모든 내용은 이전 jlox 인터프리터에서 다루었던 익숙한 영역입니다. 이번 두 번째 구현에서 새롭게 다룰 내용은, 메서드 호출 성능을 기준 성능보다 7배 이상 빠르게 만들 중요한 최적화 기법입니다. 하지만 그 재미있는 최적화에 도달하기 전에, 기본적인 기능부터 작동하도록 만들어야 합니다.
28 . 1메서드 선언
메서드 호출이 없으면 메서드 호출을 최적화할 수 없고, 호출할 메서드가 없으면 메서드를 호출할 수 없으므로, 선언부터 시작하겠습니다.
28 . 1 . 1메서드 표현
보통 컴파일러부터 시작하지만, 이번에는 객체 모델부터 먼저 처리하겠습니다. clox에서 메서드를 위한 런타임 표현은 jlox와 유사합니다. 각 클래스는 메서드의 해시 테이블을 저장합니다. 키는 메서드 이름이고, 각 값은 메서드 본문에 해당하는 ObjClosure입니다.
typedef struct {
Obj obj;
ObjString* name;
in struct ObjClass
Table methods;
} ObjClass;
새로운 클래스는 빈 메서드 테이블로 시작합니다.
klass->name = name;
in newClass()
initTable(&klass->methods);
return klass;
ObjClass 구조체는 이 테이블의 메모리를 소유하므로, 메모리 관리자가 클래스의 할당을 해제할 때 테이블도 함께 해제되어야 합니다.
case OBJ_CLASS: {
in freeObject()
ObjClass* klass = (ObjClass*)object; freeTable(&klass->methods);
FREE(ObjClass, object);
메모리 관리자에 대해 말하자면, GC(가비지 컬렉터)는 클래스를 통해 메서드 테이블을 추적해야 합니다. 클래스가 여전히 접근 가능하다면(아마도 어떤 인스턴스를 통해), 해당 클래스의 모든 메서드도 당연히 유지되어야 합니다.
markObject((Obj*)klass->name);
in blackenObject()
markTable(&klass->methods);
break;
우리는 기존의 markTable() 함수를 사용하여 각 테이블 엔트리의 키 문자열과 값을 추적합니다.
클래스의 메서드를 저장하는 것은 jlox에서 온 방식과 매우 익숙합니다. 다른 점은 그 테이블이 어떻게 채워지는지입니다. 이전 인터프리터는 클래스 선언과 그 안에 포함된 모든 메서드에 대한 전체 AST 노드에 접근할 수 있었습니다. 런타임에 인터프리터는 단순히 선언 목록을 순회했습니다.
이제 컴파일러가 런타임으로 넘기고자 하는 모든 정보는 일련의 바이트코드 명령이라는 평면적인 인터페이스를 통과해야 합니다. 임의로 많은 수의 메서드를 포함할 수 있는 클래스 선언을 어떻게 바이트코드로 표현할까요? 컴파일러로 넘어가서 알아보겠습니다.
28 . 1 . 2메서드 선언 컴파일
지난 장에서 우리는 클래스를 파싱하지만 빈 본문만 허용하는 컴파일러를 만들었습니다. 이제 중괄호 사이에 일련의 메서드 선언을 컴파일하는 코드를 조금 삽입하겠습니다.
consume(TOKEN_LEFT_BRACE, "Expect '{' before class body.");
in classDeclaration()
while (!check(TOKEN_RIGHT_BRACE) && !check(TOKEN_EOF)) { method(); }
consume(TOKEN_RIGHT_BRACE, "Expect '}' after class body.");
Lox에는 필드 선언이 없으므로, 클래스 본문 끝의 닫는 중괄호 앞에 오는 모든 것은 메서드여야 합니다. 최종 중괄호를 만나거나 파일 끝에 도달하면 메서드 컴파일을 중단합니다. 후자 확인은 사용자가 실수로 닫는 중괄호를 잊었을 경우 컴파일러가 무한 루프에 빠지는 것을 방지합니다.
클래스 선언을 컴파일할 때 어려운 부분은 클래스가 임의의 수의 메서드를 선언할 수 있다는 것입니다. 런타임은 somehow 모든 메서드를 찾아 바인딩해야 합니다. 이는 단일 OP_CLASS 명령에 담기에는 너무 많은 양일 것입니다. 대신, 클래스 선언에 대해 생성하는 바이트코드는 이 프로세스를 일련의 명령으로 분할할 것입니다. 컴파일러는 이미 새롭고 빈 ObjClass 객체를 생성하는 OP_CLASS 명령을 방출합니다. 그런 다음 해당 클래스를 이름과 함께 변수에 저장하는 명령을 방출합니다.
이제 각 메서드 선언에 대해, 해당 클래스에 단일 메서드를 추가하는 새로운 OP_METHOD 명령을 방출합니다. 모든 OP_METHOD 명령이 실행되면, 완전히 형성된 클래스가 남게 됩니다. 사용자는 클래스 선언을 하나의 원자적(atomic) 연산으로 보지만, VM은 이를 일련의 변형(mutation)으로 구현합니다.
새로운 메서드를 정의하기 위해 VM은 세 가지가 필요합니다:
-
메서드의 이름.
-
메서드 본문에 대한 클로저.
-
메서드를 바인딩할 클래스.
이것들이 런타임에 어떻게 전달되는지 컴파일러 코드를 점진적으로 작성해 보겠습니다. 여기부터 시작하겠습니다:
add after function()
static void method() { consume(TOKEN_IDENTIFIER, "Expect method name."); uint8_t constant = identifierConstant(&parser.previous); emitBytes(OP_METHOD, constant); }
OP_GET_PROPERTY와 런타임에 이름이 필요한 다른 명령들처럼, 컴파일러는 메서드 이름 토큰의 렉심(lexeme)을 상수 테이블에 추가하고, 테이블 인덱스를 반환받습니다. 그런 다음 해당 인덱스를 오퍼랜드로 사용하여 OP_METHOD 명령을 방출합니다. 이것이 이름입니다. 다음은 메서드 본문입니다:
uint8_t constant = identifierConstant(&parser.previous);
in method()
FunctionType type = TYPE_FUNCTION; function(type);
emitBytes(OP_METHOD, constant);
우리는 함수 선언을 컴파일하기 위해 작성했던 function() 헬퍼를 사용합니다. 이 유틸리티 함수는 뒤따르는 파라미터 목록과 함수 본문을 컴파일합니다. 그런 다음 ObjClosure를 생성하고 이를 스택 맨 위에 두는 코드를 방출합니다. 런타임에 VM은 거기서 클로저를 찾을 것입니다.
마지막은 메서드를 바인딩할 클래스입니다. VM은 그것을 어디서 찾을 수 있을까요? 불행히도 OP_METHOD 명령에 도달할 때쯤이면 그것이 어디에 있는지 알 수 없습니다. 사용자가 지역 스코프에서 클래스를 선언했다면 스택에 있을 수도 있습니다. 하지만 최상위 클래스 선언은 ObjClass를 전역 변수 테이블에 저장합니다.
걱정 마십시오. 컴파일러는 클래스의 이름을 알고 있습니다. 우리는 토큰을 소비한 직후에 이를 캡처할 수 있습니다.
consume(TOKEN_IDENTIFIER, "Expect class name.");
in classDeclaration()
Token className = parser.previous;
uint8_t nameConstant = identifierConstant(&parser.previous);
그리고 그 이름과 같은 다른 선언이 클래스를 가릴 수 없다는 것을 알고 있습니다. 그래서 우리는 쉬운 해결책을 적용합니다. 메서드 바인딩을 시작하기 전에, 클래스를 다시 스택 맨 위에 로드하는 데 필요한 코드를 방출합니다.
defineVariable(nameConstant);
in classDeclaration()
namedVariable(className, false);
consume(TOKEN_LEFT_BRACE, "Expect '{' before class body.");
클래스 본문을 컴파일하기 직전에 namedVariable()을 호출합니다. 이 헬퍼 함수는 주어진 이름의 변수를 스택에 로드하는 코드를 생성합니다. 그런 다음 메서드를 컴파일합니다.
이는 각 OP_METHOD 명령을 실행할 때 스택 맨 위에는 메서드의 클로저가 있고, 그 아래에는 클래스가 있다는 의미입니다. 모든 OP_METHOD 명령이 완료되면 더 이상 클래스가 필요하지 않으므로 VM에 클래스를 스택에서 제거하도록 지시합니다.
consume(TOKEN_RIGHT_BRACE, "Expect '}' after class body.");
in classDeclaration()
emitByte(OP_POP);
}
이 모든 것을 종합하여, 컴파일러에 던질 예제 클래스 선언은 다음과 같습니다:
class Brunch { bacon() {} eggs() {} }
이를 기반으로 컴파일러가 생성하는 내용과 이 명령들이 런타임에 스택에 어떻게 영향을 미치는지 보여줍니다:
남은 것은 새로운 OP_METHOD 명령에 대한 런타임을 구현하는 것뿐입니다.
28 . 1 . 3메서드 선언 실행
먼저 opcode를 정의합니다.
OP_CLASS,
in enum OpCode
OP_METHOD
} OpCode;
다른 문자열 상수 오퍼랜드를 가진 명령들처럼 이를 디스어셈블합니다.
case OP_CLASS:
return constantInstruction("OP_CLASS", chunk, offset);
in disassembleInstruction()
case OP_METHOD: return constantInstruction("OP_METHOD", chunk, offset);
default:
그리고 인터프리터에서도 새로운 케이스를 추가합니다.
break;
in run()
case OP_METHOD: defineMethod(READ_STRING()); break;
}
거기서 상수 테이블에서 메서드 이름을 읽어 이 함수로 전달합니다:
add after closeUpvalues()
static void defineMethod(ObjString* name) { Value method = peek(0); ObjClass* klass = AS_CLASS(peek(1)); tableSet(&klass->methods, name, method); pop(); }
메서드 클로저는 스택 맨 위에 있으며, 그 아래에는 바인딩될 클래스가 있습니다. 우리는 이 두 스택 슬롯을 읽고 클로저를 클래스의 메서드 테이블에 저장합니다. 그런 다음 클로저를 사용했으므로 스택에서 제거합니다.
클로저나 클래스 객체에 대해 런타임 타입 검사를 수행하지 않는다는 점에 유의하십시오. AS_CLASS() 호출은 안전합니다. 왜냐하면 컴파일러 자체가 클래스를 해당 스택 슬롯에 배치하는 코드를 생성했기 때문입니다. VM은 자체 컴파일러를 신뢰합니다.
일련의 OP_METHOD 명령이 완료되고 OP_POP이 클래스를 스택에서 제거한 후, 우리는 잘 채워진 메서드 테이블을 가진 클래스를 가지게 되어, 이제 작업을 시작할 준비가 되었습니다. 다음 단계는 그 메서드들을 다시 가져와서 사용하는 것입니다.
28 . 2메서드 참조
대부분의 경우 메서드는 접근되고 즉시 호출되어, 다음과 같은 익숙한 구문으로 이어집니다:
instance.method(argument);
하지만 Lox와 일부 다른 언어에서는 이 두 단계가 구별되며 분리될 수 있다는 점을 기억하십시오.
var closure = instance.method; closure(argument);
사용자는 작업을 분리할 수 있으므로, 우리는 이들을 별도로 구현해야 합니다. 첫 번째 단계는 기존의 점(dot) 프로퍼티 구문을 사용하여 인스턴스 클래스에 정의된 메서드에 접근하는 것입니다. 이는 사용자가 함수처럼 호출할 수 있는 일종의 객체를 반환해야 합니다.
가장 명확한 접근 방식은 클래스의 메서드 테이블에서 메서드를 찾아 해당 이름과 연결된 ObjClosure를 반환하는 것입니다. 하지만 메서드에 접근할 때 this가 메서드가 접근된 인스턴스에 바인딩된다는 점도 기억해야 합니다. jlox에 메서드를 추가했을 때의 예제는 다음과 같습니다:
class Person { sayName() { print this.name; } } var jane = Person(); jane.name = "Jane"; var method = jane.sayName; method(); // ?
이것은 "Jane"을 출력해야 하므로, .sayName이 반환한 객체는 나중에 호출될 때 메서드가 접근된 인스턴스를 어떤 식으로든 기억해야 합니다. jlox에서는 인터프리터의 기존 힙 할당 Environment 클래스를 사용하여 이 "기억"을 구현했습니다. Environment 클래스는 모든 변수 저장소를 처리했습니다.
우리 바이트코드 VM은 상태 저장에 더 복잡한 아키텍처를 가지고 있습니다. 지역 변수와 임시 변수는 스택에, 전역 변수는 해시 테이블에, 그리고 클로저의 변수는 업밸류를 사용합니다. 이로 인해 clox에서 메서드의 리시버(receiver)를 추적하는 데 다소 더 복잡한 솔루션과 새로운 런타임 타입이 필요합니다.
28 . 2 . 1바인딩된 메서드
사용자가 메서드 접근을 실행할 때, 우리는 해당 메서드의 클로저를 찾아 메서드가 접근된 인스턴스를 추적하는 새로운 "바인딩된 메서드(bound method)" 객체로 래핑할 것입니다. 이 바인딩된 객체는 나중에 함수처럼 호출될 수 있습니다. 호출될 때, VM은 메서드 본문 내에서 this가 리시버를 가리키도록 어떤 조작을 수행할 것입니다.
새로운 객체 타입은 다음과 같습니다:
} ObjInstance;
add after struct ObjInstance
typedef struct { Obj obj; Value receiver; ObjClosure* method; } ObjBoundMethod;
ObjClass* newClass(ObjString* name);
리시버와 메서드 클로저를 함께 래핑합니다. 메서드는 ObjInstance에서만 호출될 수 있지만, 리시버의 타입은 Value입니다. VM은 어차피 어떤 종류의 리시버를 가지고 있는지 신경 쓰지 않으므로, Value를 사용하면 더 일반적인 함수로 전달될 때마다 포인터를 다시 Value로 변환할 필요가 없습니다.
새로운 구조체는 이제 익숙한 상용구(boilerplate)를 의미합니다. 객체 타입 enum에 새로운 케이스를 추가합니다:
typedef enum {
in enum ObjType
OBJ_BOUND_METHOD,
OBJ_CLASS,
값의 타입을 확인하는 매크로:
#define OBJ_TYPE(value) (AS_OBJ(value)->type)
#define IS_BOUND_METHOD(value) isObjType(value, OBJ_BOUND_METHOD)
#define IS_CLASS(value) isObjType(value, OBJ_CLASS)
값을 ObjBoundMethod 포인터로 캐스팅하는 또 다른 매크로:
#define IS_STRING(value) isObjType(value, OBJ_STRING)
#define AS_BOUND_METHOD(value) ((ObjBoundMethod*)AS_OBJ(value))
#define AS_CLASS(value) ((ObjClass*)AS_OBJ(value))
새로운 ObjBoundMethod를 생성하는 함수:
} ObjBoundMethod;
add after struct ObjBoundMethod
ObjBoundMethod* newBoundMethod(Value receiver, ObjClosure* method);
ObjClass* newClass(ObjString* name);
그리고 해당 함수의 구현은 다음과 같습니다:
add after allocateObject()
ObjBoundMethod* newBoundMethod(Value receiver, ObjClosure* method) { ObjBoundMethod* bound = ALLOCATE_OBJ(ObjBoundMethod, OBJ_BOUND_METHOD); bound->receiver = receiver; bound->method = method; return bound; }
생성자(constructor)와 같은 함수는 주어진 클로저와 리시버를 단순히 저장합니다. 바인딩된 메서드가 더 이상 필요 없으면 해제합니다.
switch (object->type) {
in freeObject()
case OBJ_BOUND_METHOD: FREE(ObjBoundMethod, object); break;
case OBJ_CLASS: {
바인딩된 메서드는 몇 가지 참조를 가지고 있지만, 이를 소유하지는 않으므로 자신 외에는 아무것도 해제하지 않습니다. 그러나 이러한 참조는 가비지 컬렉터에 의해 추적됩니다.
switch (object->type) {
in blackenObject()
case OBJ_BOUND_METHOD: { ObjBoundMethod* bound = (ObjBoundMethod*)object; markValue(bound->receiver); markObject((Obj*)bound->method); break; }
case OBJ_CLASS: {
이것은 메서드에 대한 핸들이 리시버를 메모리에 유지하여 나중에 핸들을 호출할 때 this가 객체를 여전히 찾을 수 있도록 보장합니다. 또한 메서드 클로저도 추적합니다.
모든 객체가 지원하는 마지막 작업은 출력입니다.
switch (OBJ_TYPE(value)) {
in printObject()
case OBJ_BOUND_METHOD: printFunction(AS_BOUND_METHOD(value)->method->function); break;
case OBJ_CLASS:
바인딩된 메서드는 함수와 정확히 동일한 방식으로 출력됩니다. 사용자 관점에서는 바인딩된 메서드가 함수입니다. 이는 호출할 수 있는 객체입니다. VM이 바인딩된 메서드를 다른 객체 타입으로 구현한다는 사실을 노출하지 않습니다.
파티 모자를 쓰세요. 이제 작은 이정표에 도달했습니다. ObjBoundMethod는 clox에 추가할 마지막 런타임 타입입니다. 마지막 IS_ 및 AS_ 매크로를 작성했습니다. 책의 끝까지 몇 장 남지 않았고, 완전한 VM에 가까워지고 있습니다.
28 . 2 . 2메서드 접근
메서드는 지난 장에서 구현한 것과 동일한 "점(dot)" 프로퍼티 구문을 사용하여 접근됩니다. 컴파일러는 이미 올바른 표현식을 파싱하고 OP_GET_PROPERTY 명령을 방출합니다. 우리가 변경해야 할 유일한 부분은 런타임입니다.
프로퍼티 접근 명령이 실행될 때, 인스턴스는 스택 맨 위에 있습니다. 명령의 역할은 주어진 이름의 필드 또는 메서드를 찾아 스택 맨 위를 접근된 프로퍼티로 대체하는 것입니다.
인터프리터는 이미 필드를 처리하므로, OP_GET_PROPERTY 케이스에 다른 섹션을 추가하기만 하면 됩니다.
pop(); // Instance.
push(value);
break;
}
in run()
replace 2 lines
if (!bindMethod(instance->klass, name)) { return INTERPRET_RUNTIME_ERROR; } break;
}
우리는 리시버 인스턴스에서 필드를 찾는 코드 뒤에 이 코드를 삽입합니다. 필드는 메서드보다 우선순위를 가지므로, 먼저 필드를 찾습니다. 인스턴스에 주어진 프로퍼티 이름의 필드가 없으면, 그 이름은 메서드를 가리킬 수 있습니다.
우리는 인스턴스의 클래스를 가져와 새로운 bindMethod() 헬퍼에 전달합니다. 이 함수가 메서드를 찾으면 메서드를 스택에 배치하고 true를 반환합니다. 그렇지 않으면 해당 이름의 메서드를 찾을 수 없음을 나타내기 위해 false를 반환합니다. 이름이 필드도 아니었으므로, 이는 런타임 오류를 의미하며 인터프리터를 중단시킵니다.
여기 좋은 내용이 있습니다:
add after callValue()
static bool bindMethod(ObjClass* klass, ObjString* name) { Value method; if (!tableGet(&klass->methods, name, &method)) { runtimeError("Undefined property '%s'.", name->chars); return false; } ObjBoundMethod* bound = newBoundMethod(peek(0), AS_CLOSURE(method)); pop(); push(OBJ_VAL(bound)); return true; }
먼저 클래스의 메서드 테이블에서 주어진 이름의 메서드를 찾습니다. 찾지 못하면 런타임 오류를 보고하고 종료합니다. 그렇지 않으면 메서드를 가져와 새로운 ObjBoundMethod로 래핑합니다. 스택 맨 위에 있는 리시버를 가져옵니다. 마지막으로 인스턴스를 제거하고 스택 맨 위를 바인딩된 메서드로 대체합니다.
예를 들어:
class Brunch { eggs() {} } var brunch = Brunch(); var eggs = brunch.eggs;
VM이 brunch.eggs 표현식에 대한 bindMethod() 호출을 실행할 때 발생하는 일은 다음과 같습니다:
내부적으로 많은 작업이 이루어지지만, 사용자 관점에서는 단순히 호출할 수 있는 함수를 얻는 것입니다.
28 . 2 . 3메서드 호출
사용자는 클래스에 메서드를 선언하고, 인스턴스에서 메서드에 접근하여 바인딩된 메서드를 스택에 올릴 수 있습니다. 하지만 그 바인딩된 메서드 객체로 유용한 것을 할 수 없습니다. 우리가 빠뜨린 작업은 그들을 호출하는 것입니다. 호출은 callValue()에서 구현되므로, 새로운 객체 타입에 대한 케이스를 추가합니다.
switch (OBJ_TYPE(callee)) {
in callValue()
case OBJ_BOUND_METHOD: { ObjBoundMethod* bound = AS_BOUND_METHOD(callee); return call(bound->method, argCount); }
case OBJ_CLASS: {
ObjBoundMethod에서 원본 클로저를 다시 가져와서 기존의 call() 헬퍼를 사용하여 CallFrame을 호출 스택에 푸시함으로써 해당 클로저 호출을 시작합니다. 이 Lox 프로그램을 실행할 수 있게 되는 것은 이 정도면 충분합니다:
class Scone { topping(first, second) { print "scone with " + first + " and " + second; } } var scone = Scone(); scone.topping("berries", "cream");
세 가지 큰 단계가 있습니다. 우리는 메서드를 선언하고, 인스턴스에서 접근하며, 호출할 수 있습니다. 하지만 뭔가 빠졌습니다. 메서드 클로저를 리시버를 바인딩하는 객체로 래핑하는 데 모든 노력을 기울였지만, 메서드를 호출할 때는 그 리시버를 전혀 사용하지 않습니다.
28 . 3this
바인딩된 메서드가 리시버를 유지해야 하는 이유는 메서드 본문 내에서 접근할 수 있도록 하기 위함입니다. Lox는 this 표현식을 통해 메서드의 리시버를 노출합니다. 이제 새로운 구문이 필요합니다. 렉서(lexer)는 이미 this를 특별한 토큰 타입으로 처리하므로, 첫 번째 단계는 해당 토큰을 파스 테이블에 연결하는 것입니다.
[TOKEN_SUPER] = {NULL, NULL, PREC_NONE},
replace 1 line
[TOKEN_THIS] = {this_, NULL, PREC_NONE},
[TOKEN_TRUE] = {literal, NULL, PREC_NONE},
파서가 접두사 위치에서 this를 만나면 새로운 파서 함수로 디스패치합니다.
add after variable()
static void this_(bool canAssign) { variable(false); }
clox에서 this에 대해 jlox에서 사용했던 것과 동일한 구현 기술을 적용할 것입니다. 우리는 this를 값이 마법처럼 초기화되는 어휘적으로(lexically) 스코프된 지역 변수로 취급합니다. 이를 지역 변수처럼 컴파일하면 많은 동작을 무료로 얻을 수 있습니다. 특히 메서드 내에서 this를 참조하는 클로저는 올바르게 작동하고 리시버를 업밸류에 캡처할 것입니다.
파서 함수가 호출될 때, this 토큰은 방금 소비되어 이전 토큰으로 저장됩니다. 우리는 기존의 variable() 함수를 호출하는데, 이 함수는 식별자 표현식을 변수 접근으로 컴파일합니다. 이 함수는 컴파일러가 후속 = 연산자를 찾아 세터(setter)를 파싱해야 하는지 여부를 나타내는 단일 불리언 매개변수를 받습니다. this에는 할당할 수 없으므로, 이를 허용하지 않기 위해 false를 전달합니다.
variable() 함수는 this가 자체 토큰 타입을 가지고 있고 식별자가 아니라는 사실을 신경 쓰지 않습니다. 렉심 "this"를 변수 이름처럼 취급하며 기존의 스코프 해상 메커니즘을 사용하여 찾습니다. 현재로서는 이름이 "this"인 변수를 선언한 적이 없으므로 이 찾기는 실패할 것입니다. 이제 리시버가 메모리에 어디에 위치해야 하는지 생각할 때입니다.
클로저에 의해 캡처되기 전까지, clox는 모든 지역 변수를 VM의 스택에 저장합니다. 컴파일러는 함수의 스택 윈도우에서 어떤 슬롯이 어떤 지역 변수에 의해 소유되는지를 추적합니다. 기억하시겠지만, 컴파일러는 이름이 빈 문자열인 지역 변수를 선언하여 스택 슬롯 0을 할당합니다.
함수 호출의 경우, 해당 슬롯은 호출되는 함수를 저장하게 됩니다. 슬롯에 이름이 없으므로 함수 본문은 이 슬롯에 접근하지 않습니다. 이 이야기가 어디로 향하고 있는지 짐작할 수 있을 것입니다. 메서드 호출의 경우, 이 슬롯을 리시버를 저장하는 데 재활용할 수 있습니다. 슬롯 0은 this가 바인딩된 인스턴스를 저장할 것입니다. this 표현식을 컴파일하기 위해 컴파일러는 단순히 해당 지역 변수에 올바른 이름을 부여하기만 하면 됩니다.
local->isCaptured = false;
in initCompiler()
replace 2 lines
if (type != TYPE_FUNCTION) { local->name.start = "this"; local->name.length = 4; } else { local->name.start = ""; local->name.length = 0; }
}
이것은 메서드에 대해서만 하고 싶습니다. 함수 선언에는 this가 없습니다. 그리고 실제로, 함수 선언은 "this"라는 이름의 변수를 선언해서는 안 됩니다. 그래야 메서드 안에 중첩된 함수 선언 내부에서 this 표현식을 작성할 때, this가 바깥 메서드의 리시버로 올바르게 해석됩니다.
class Nested { method() { fun function() { print this; } function(); } } Nested().method();
이 프로그램은 "Nested instance"를 출력해야 합니다. 지역 슬롯 0에 어떤 이름을 부여할지 결정하기 위해 컴파일러는 함수 선언을 컴파일하는지 메서드 선언을 컴파일하는지 알아야 하므로, 메서드를 구별하기 위해 FunctionType enum에 새로운 케이스를 추가합니다.
TYPE_FUNCTION,
in enum FunctionType
TYPE_METHOD,
TYPE_SCRIPT
메서드를 컴파일할 때는 해당 타입을 사용합니다.
uint8_t constant = identifierConstant(&parser.previous);
in method()
replace 1 line
FunctionType type = TYPE_METHOD;
function(type);
이제 특수 "this" 변수에 대한 참조를 올바르게 컴파일할 수 있으며, 컴파일러는 해당 변수에 접근하기 위해 올바른 OP_GET_LOCAL 명령을 방출할 것입니다. 클로저는 심지어 this를 캡처하여 리시버를 업밸류에 저장할 수도 있습니다. 꽤 멋집니다.
하지만 런타임에는 리시버가 실제로 슬롯 0에 없습니다. 인터프리터가 아직 이 약속을 이행하지 않고 있습니다. 수정 사항은 다음과 같습니다:
case OBJ_BOUND_METHOD: {
ObjBoundMethod* bound = AS_BOUND_METHOD(callee);
in callValue()
vm.stackTop[-argCount - 1] = bound->receiver;
return call(bound->method, argCount);
}
메서드가 호출될 때, 스택 맨 위에는 모든 인수가 포함되어 있고, 그 바로 아래에는 호출된 메서드의 클로저가 있습니다. 이것이 새 CallFrame의 슬롯 0이 될 위치입니다. 이 코드는 리시버를 해당 슬롯에 삽입합니다. 예를 들어, 다음과 같은 메서드 호출이 주어졌을 때:
scone.topping("berries", "cream");
리시버를 저장할 슬롯은 다음과 같이 계산합니다:
-argCount는 인수를 건너뛰고, - 1은 stackTop이 마지막으로 사용된 스택 슬롯 다음을 가리키는 사실을 조정합니다.
28 . 3 . 1this 오용
이제 VM은 this를 올바르게 사용하는 것을 지원하지만, 사용자가 this를 잘못 사용하는 경우도 적절히 처리해야 합니다. Lox는 this 표현식이 메서드 본문 외부에서 나타나는 것을 컴파일 오류로 간주합니다. 다음 두 가지 잘못된 사용은 컴파일러에 의해 감지되어야 합니다:
print this; // 최상위 레벨에서. fun notMethod() { print this; // 함수 내에서. }
그렇다면 컴파일러는 메서드 내부에 있는지 어떻게 알까요? 명백한 답은 현재 Compiler의 FunctionType을 확인하는 것입니다. 우리는 메서드를 특별하게 처리하기 위해 enum 케이스를 추가했습니다. 하지만 이 방법은 앞서 언급한, 메서드 안에 중첩된 함수 내부에 있을 때와 같은 코드를 올바르게 처리하지 못할 것입니다.
우리는 "this"를 해결하려 시도하고, 주변 어휘 스코프에서 찾지 못하면 오류를 보고할 수 있습니다. 이것은 작동하겠지만, 현재 변수를 해결하는 코드가 선언을 찾지 못하면 암묵적으로 전역 접근으로 간주하기 때문에 많은 코드를 재배치해야 할 것입니다.
다음 장에서는 가장 가까운 둘러싸는 클래스에 대한 정보가 필요할 것입니다. 그 정보가 있다면, 우리가 메서드 내부에 있는지 여부를 판단하는 데 사용할 수 있습니다. 따라서 미래의 우리 자신을 위해 미리 그 메커니즘을 마련하는 것이 좋습니다.
Compiler* current = NULL;
add after variable current
ClassCompiler* currentClass = NULL;
static Chunk* currentChunk() {
이 모듈 변수는 현재 컴파일 중인 가장 안쪽 클래스를 나타내는 구조체를 가리킵니다. 새로운 타입은 다음과 같습니다:
} Compiler;
add after struct Compiler
typedef struct ClassCompiler { struct ClassCompiler* enclosing; } ClassCompiler;
Parser parser;
지금은 (있는 경우) 둘러싸는 클래스를 위한 ClassCompiler에 대한 포인터만 저장합니다. 다른 클래스의 메서드 안에 클래스 선언을 중첩하는 것은 흔하지 않은 일이지만, Lox는 이를 지원합니다. Compiler 구조체와 마찬가지로, ClassCompiler는 현재 컴파일 중인 가장 안쪽 클래스부터 모든 둘러싸는 클래스까지 연결된 리스트를 형성합니다.
어떤 클래스 선언 내부에도 있지 않다면, 모듈 변수 currentClass는 NULL입니다. 컴파일러가 클래스 컴파일을 시작할 때, 새로운 ClassCompiler를 해당 암묵적 연결 스택에 푸시합니다.
defineVariable(nameConstant);
in classDeclaration()
ClassCompiler classCompiler; classCompiler.enclosing = currentClass; currentClass = &classCompiler;
namedVariable(className, false);
ClassCompiler 구조체를 위한 메모리는 C 스택에 직접 존재하며, 이는 재귀 하향 파서를 사용하여 컴파일러를 작성할 때 얻는 편리한 기능입니다. 클래스 본문 끝에서는 해당 컴파일러를 스택에서 제거하고 둘러싸는 컴파일러를 복원합니다.
emitByte(OP_POP);
in classDeclaration()
currentClass = currentClass->enclosing;
}
가장 바깥 클래스 본문이 끝나면 enclosing은 NULL이므로, currentClass는 NULL로 재설정됩니다. 따라서 우리가 클래스 내부에 있는지(그리고 따라서 메서드 내부에 있는지) 확인하려면 단순히 해당 모듈 변수를 확인하면 됩니다.
static void this_(bool canAssign) {
in this_()
if (currentClass == NULL) { error("Can't use 'this' outside of a class."); return; }
variable(false);
이것으로 클래스 외부에서의 this 사용은 올바르게 금지됩니다. 이제 우리 메서드들은 객체 지향적인 의미에서 진정한 메서드처럼 느껴집니다. 리시버에 접근함으로써 메서드가 호출된 인스턴스에 영향을 미칠 수 있습니다. 점점 더 나아가고 있습니다!
28 . 4인스턴스 초기화자
객체 지향 언어가 상태와 동작을 함께 묶는 이유(이 패러다임의 핵심 신조 중 하나)는 객체가 항상 유효하고 의미 있는 상태를 유지하도록 보장하기 위함입니다. 객체의 상태에 접근할 수 있는 유일한 방법이 메서드를 통하는 것이라면, 메서드는 어떤 문제도 발생하지 않도록 할 수 있습니다. 하지만 이는 객체가 이미 올바른 상태에 있다고 가정합니다. 처음 생성될 때는 어떨까요?
객체 지향 언어는 생성자를 통해 새로 생성된 객체가 올바르게 설정되도록 보장합니다. 생성자는 새 인스턴스를 생성하고 그 상태를 초기화합니다. Lox에서는 런타임이 새로운 원시(raw) 인스턴스를 할당하고, 클래스는 필드를 설정하기 위한 초기화자를 선언할 수 있습니다. 초기화자는 몇 가지 수정 사항을 제외하고는 일반 메서드와 거의 동일하게 작동합니다:
-
클래스의 인스턴스가 생성될 때마다 런타임이 초기화자 메서드를 자동으로 호출합니다.
-
인스턴스를 생성하는 호출자는 초기화자가 완료된 후 항상 인스턴스를 돌려받습니다. 초기화자 함수 자체가 무엇을 반환하든 상관없습니다. 초기화자 메서드는 명시적으로
this를 반환할 필요가 없습니다. -
사실, 초기화자는 어떤 값도 반환하는 것이 금지됩니다. 왜냐하면 그 값은 어차피 볼 수 없기 때문입니다.
이제 메서드를 지원하므로, 초기화자를 추가하려면 단순히 세 가지 특별 규칙을 구현하면 됩니다. 순서대로 진행하겠습니다.
28 . 4 . 1초기화자 호출
첫째, 새 인스턴스에 대해 init()을 자동으로 호출합니다:
vm.stackTop[-argCount - 1] = OBJ_VAL(newInstance(klass));
in callValue()
Value initializer; if (tableGet(&klass->methods, vm.initString, &initializer)) { return call(AS_CLOSURE(initializer), argCount); }
return true;
런타임이 새 인스턴스를 할당한 후, 클래스에 init() 메서드가 있는지 찾습니다. 찾으면 해당 메서드 호출을 시작합니다. 이렇게 하면 초기화자 클로저를 위한 새 CallFrame이 푸시됩니다. 다음 프로그램을 실행한다고 가정해 보겠습니다:
class Brunch { init(food, drink) {} } Brunch("eggs", "coffee");
VM이 Brunch() 호출을 실행할 때 다음과 같이 진행됩니다:
클래스를 호출할 때 전달된 모든 인수는 인스턴스 위에 스택에 여전히 남아 있습니다. init() 메서드를 위한 새 CallFrame은 해당 스택 윈도우를 공유하므로, 이 인수들은 암묵적으로 초기화자로 전달됩니다.
Lox는 클래스가 초기화자를 정의할 것을 요구하지 않습니다. 생략되면 런타임은 단순히 새로운 초기화되지 않은 인스턴스를 반환합니다. 그러나 init() 메서드가 없으면 인스턴스를 생성할 때 클래스에 인수를 전달하는 것은 의미가 없습니다. 이를 오류로 처리합니다.
return call(AS_CLOSURE(initializer), argCount);
in callValue()
} else if (argCount != 0) { runtimeError("Expected 0 arguments but got %d.", argCount); return false;
}
클래스가 초기화자를 제공하는 경우, 전달된 인수 수가 초기화자의 아리티(arity)와 일치하는지 확인해야 합니다. 다행히 call() 헬퍼는 이미 이 작업을 수행합니다.
초기화자를 호출하기 위해 런타임은 이름으로 init() 메서드를 찾습니다. 이는 인스턴스가 생성될 때마다 발생하므로 빠르게 이루어져야 합니다. 즉, 이미 구현한 문자열 인터닝(interning)을 활용하는 것이 좋습니다. 이를 위해 VM은 "init"에 대한 ObjString을 생성하고 이를 재사용합니다. 이 문자열은 VM 구조체에 직접 존재합니다.
Table strings;
in struct VM
ObjString* initString;
ObjUpvalue* openUpvalues;
VM이 시작될 때 문자열을 생성하고 인터닝(interning)합니다.
initTable(&vm.strings);
in initVM()
vm.initString = copyString("init", 4);
defineNative("clock", clockNative);
이것이 계속 유지되도록 GC는 이를 루트(root)로 간주합니다.
markCompilerRoots();
in markRoots()
markObject((Obj*)vm.initString);
}
자세히 살펴보세요. 발생할 버그가 보이나요? 아니요? 미묘한 문제입니다. 가비지 컬렉터는 이제 vm.initString을 읽습니다. 이 필드는 copyString() 호출 결과로 초기화됩니다. 하지만 문자열을 복사하면 메모리가 할당될 수 있으며, 이는 GC를 트리거할 수 있습니다. 컬렉터가 잘못된 타이밍에 실행되면, 초기화되기 전에 vm.initString을 읽게 될 것입니다. 따라서 먼저 필드를 0으로 초기화합니다.
initTable(&vm.strings);
in initVM()
vm.initString = NULL;
vm.initString = copyString("init", 4);
VM이 종료될 때 포인터를 지웁니다. 다음 줄에서 이를 해제할 것이기 때문입니다.
freeTable(&vm.strings);
in freeVM()
vm.initString = NULL;
freeObjects();
좋습니다, 이제 초기화자를 호출할 수 있습니다.
28 . 4 . 2초기화자 반환 값
다음 단계는 초기화자가 있는 클래스의 인스턴스를 구성하면 항상 새 인스턴스를 반환하고, nil이나 초기화자 본문이 반환하는 값은 반환하지 않도록 하는 것입니다. 현재 클래스가 초기화자를 정의하면 인스턴스가 구성될 때 VM은 해당 초기화자에 대한 호출을 CallFrame 스택에 푸시합니다. 그런 다음 계속 실행됩니다.
사용자가 클래스에서 인스턴스를 생성하기 위해 호출한 것은 해당 초기화자 메서드가 반환할 때 완료되며, 초기화자가 스택에 놓은 값을 남깁니다. 이는 사용자가 초기화자 끝에 return this;를 명시적으로 작성하지 않으면 인스턴스가 반환되지 않는다는 의미입니다. 그다지 도움이 되지 않습니다.
이것을 해결하기 위해, 프론트엔드가 초기화자 메서드를 컴파일할 때마다, 본문 끝에 일반적인 암묵적 nil을 반환하는 대신 this를 메서드에서 반환하도록 다른 바이트코드를 방출할 것입니다. 이를 위해 컴파일러는 초기화자를 컴파일할 때 실제로 이를 알아야 합니다. 우리는 컴파일 중인 메서드의 이름이 "init"인지 확인하여 이를 감지합니다.
FunctionType type = TYPE_METHOD;
in method()
replace 1 line
if (parser.previous.length == 4 && memcmp(parser.previous.start, "init", 4) == 0) { type = TYPE_INITIALIZER; }
function(type);
우리는 초기화자를 다른 메서드와 구별하기 위해 새로운 함수 타입을 정의합니다.
TYPE_FUNCTION,
in enum FunctionType
TYPE_INITIALIZER,
TYPE_METHOD,
컴파일러가 본문 끝에 암묵적 반환을 방출할 때마다, 초기화자 특정 동작을 삽입할지 여부를 결정하기 위해 타입을 확인합니다.
static void emitReturn() {
in emitReturn()
replace 1 line
if (current->type == TYPE_INITIALIZER) { emitBytes(OP_GET_LOCAL, 0); } else { emitByte(OP_NIL); }
emitByte(OP_RETURN);
초기화자에서는 반환 전에 nil을 스택에 푸시하는 대신, 인스턴스가 포함된 슬롯 0을 로드합니다. 이 emitReturn() 함수는 값을 가지지 않는 return 문을 컴파일할 때도 호출되므로, 사용자가 초기화자 내에서 조기 반환을 하는 경우도 올바르게 처리합니다.
28 . 4 . 3초기화자의 잘못된 반환
마지막 단계, 즉 초기화자의 특별 기능 목록의 마지막 항목은 초기화자에서 다른 것을 반환하려고 시도하는 것을 오류로 만드는 것입니다. 이제 컴파일러가 메서드 타입을 추적하므로, 이는 간단합니다.
if (match(TOKEN_SEMICOLON)) {
emitReturn();
} else {
in returnStatement()
if (current->type == TYPE_INITIALIZER) { error("Can't return a value from an initializer."); }
expression();
초기화자 내의 return 문에 값이 있으면 오류를 보고합니다. 컴파일러가 후행 표현식으로 인해 혼란스러워하지 않고 많은 연쇄 오류를 보고하지 않도록, 우리는 계속해서 값을 컴파일합니다.
곧 다룰 상속을 제외하고, 이제 clox에서 상당히 기능이 풍부한 클래스 시스템이 작동하고 있습니다.
class CoffeeMaker { init(coffee) { this.coffee = coffee; } brew() { print "Enjoy your cup of " + this.coffee; // 더 이상 커피 찌꺼기를 재사용하지 않습니다! this.coffee = nil; } } var maker = CoffeeMaker("coffee and chicory"); maker.brew();
오래된 플로피 디스크에 들어갈 만한 C 프로그램치고는 꽤 멋집니다.
28 . 5최적화된 호출
우리 VM은 메서드 호출과 초기화자에 대한 언어의 의미론을 올바르게 구현합니다. 여기서 멈출 수도 있습니다. 하지만 우리가 Lox의 전체 두 번째 구현을 처음부터 구축하는 주된 이유는 이전 Java 인터프리터보다 빠르게 실행하기 위함입니다. 현재 clox에서도 메서드 호출은 느립니다.
Lox의 의미론은 메서드 호출을 두 가지 연산(메서드에 접근하고 그 결과를 호출하는 것)으로 정의합니다. 사용자가 이들을 분리할 수 있으므로, 우리 VM은 이들을 별개의 연산으로 지원해야 합니다. 메서드를 호출하지 않고 접근한 다음, 나중에 바인딩된 메서드를 호출할 수 있습니다. 지금까지 구현한 것 중 불필요한 것은 없습니다.
하지만 이들을 항상 별개의 연산으로 실행하는 것은 상당한 비용을 초래합니다. Lox 프로그램이 메서드에 접근하고 호출할 때마다 런타임은 새로운 ObjBoundMethod를 힙 할당하고, 필드를 초기화한 다음, 다시 꺼냅니다. 나중에 GC는 이러한 일시적인 바인딩된 메서드를 모두 해제하는 데 시간을 소비해야 합니다.
대부분의 경우, Lox 프로그램은 메서드에 접근한 다음 즉시 호출합니다. 바인딩된 메서드는 하나의 바이트코드 명령으로 생성되고, 그 다음 명령으로 소비됩니다. 사실, 너무 즉각적이어서 컴파일러는 이것이 발생하고 있다는 것을 텍스트적으로도 볼 수 있습니다. 점(dot) 프로퍼티 접근 뒤에 여는 괄호가 오면, 이는 거의 확실히 메서드 호출입니다.
이 두 가지 연산 쌍을 컴파일 시간에 인식할 수 있으므로, 최적화된 메서드 호출을 수행하는 새롭고 특별한 명령을 방출할 기회가 있습니다.
점(dot) 프로퍼티 표현식을 컴파일하는 함수에서 시작하겠습니다.
if (canAssign && match(TOKEN_EQUAL)) {
expression();
emitBytes(OP_SET_PROPERTY, name);
in dot()
} else if (match(TOKEN_LEFT_PAREN)) { uint8_t argCount = argumentList(); emitBytes(OP_INVOKE, name); emitByte(argCount);
} else {
컴파일러가 프로퍼티 이름을 파싱한 후, 여는 괄호를 찾습니다. 괄호를 찾으면 새로운 코드 경로로 전환합니다. 거기서 호출 표현식을 컴파일할 때와 동일하게 인수 목록을 컴파일합니다. 그런 다음 단일의 새로운 OP_INVOKE 명령을 방출합니다. 이 명령은 두 개의 오퍼랜드를 가집니다:
-
상수 테이블 내 프로퍼티 이름의 인덱스.
-
메서드에 전달된 인수의 수.
즉, 이 단일 명령은 대체하는 OP_GET_PROPERTY 및 OP_CALL 명령의 오퍼랜드를 순서대로 결합합니다. 실제로 이 두 명령의 융합입니다. 이제 이를 정의해 봅시다.
OP_CALL,
in enum OpCode
OP_INVOKE,
OP_CLOSURE,
그리고 디스어셈블러에 추가합니다:
case OP_CALL:
return byteInstruction("OP_CALL", chunk, offset);
in disassembleInstruction()
case OP_INVOKE: return invokeInstruction("OP_INVOKE", chunk, offset);
case OP_CLOSURE: {
이것은 새로운 특수 명령 형식이기 때문에 약간의 맞춤형 디스어셈블리 로직이 필요합니다.
add after constantInstruction()
static int invokeInstruction(const char* name, Chunk* chunk, int offset) { uint8_t constant = chunk->code[offset + 1]; uint8_t argCount = chunk->code[offset + 2]; printf("%-16s (%d args) %4d '", name, argCount, constant); printValue(chunk->constants.values[constant]); printf("'\n"); return offset + 3; }
두 오퍼랜드를 읽고 메서드 이름과 인수 수를 모두 출력합니다. 인터프리터의 바이트코드 디스패치 루프에서 실제 작업이 시작됩니다.
}
in run()
case OP_INVOKE: { ObjString* method = READ_STRING(); int argCount = READ_BYTE(); if (!invoke(method, argCount)) { return INTERPRET_RUNTIME_ERROR; } frame = &vm.frames[vm.frameCount - 1]; break; }
case OP_CLOSURE: {
대부분의 작업은 invoke()에서 발생합니다. 나중에 다룰 것입니다. 여기서는 첫 번째 오퍼랜드에서 메서드 이름을 찾아 읽고, 인수 수 오퍼랜드를 읽습니다. 그런 다음 invoke()에 넘겨서 무거운 작업을 수행하게 합니다. 이 함수는 호출이 성공하면 true를 반환합니다. 일반적으로 false 반환은 런타임 오류가 발생했음을 의미합니다. 여기서 이를 확인하고 재난이 발생하면 인터프리터를 중단합니다.
마지막으로, 호출이 성공했다고 가정하면 스택에 새 CallFrame이 있으므로, frame에 현재 프레임의 캐시된 복사본을 새로 고칩니다.
흥미로운 작업은 여기서 일어납니다:
add after callValue()
static bool invoke(ObjString* name, int argCount) { Value receiver = peek(argCount); ObjInstance* instance = AS_INSTANCE(receiver); return invokeFromClass(instance->klass, name, argCount); }
먼저 스택에서 리시버를 가져옵니다. 메서드에 전달된 인수들은 스택의 그 위에 있으므로, 해당 수만큼 슬롯을 내려다봅니다. 그런 다음 객체를 인스턴스로 캐스팅하고 그 위에서 메서드를 호출하는 것은 간단한 문제입니다.
이는 객체가 인스턴스라고 가정하는 것입니다. OP_GET_PROPERTY 명령과 마찬가지로, 사용자가 잘못된 타입의 값에 대해 메서드를 호출하려고 시도하는 경우도 처리해야 합니다.
Value receiver = peek(argCount);
in invoke()
if (!IS_INSTANCE(receiver)) { runtimeError("Only instances have methods."); return false; }
ObjInstance* instance = AS_INSTANCE(receiver);
이는 런타임 오류이므로, 오류를 보고하고 종료합니다. 그렇지 않으면 인스턴스의 클래스를 가져와 이 다른 새로운 유틸리티 함수로 이동합니다:
add after callValue()
static bool invokeFromClass(ObjClass* klass, ObjString* name, int argCount) { Value method; if (!tableGet(&klass->methods, name, &method)) { runtimeError("Undefined property '%s'.", name->chars); return false; } return call(AS_CLOSURE(method), argCount); }
이 함수는 OP_GET_PROPERTY 및 OP_CALL 명령이 VM에서 구현되는 논리를 순서대로 결합합니다. 먼저 클래스의 메서드 테이블에서 이름으로 메서드를 찾습니다. 찾지 못하면 런타임 오류를 보고하고 종료합니다.
그렇지 않으면 메서드의 클로저를 가져와 CallFrame 스택에 호출을 푸시합니다. ObjBoundMethod를 힙 할당하고 초기화할 필요가 없습니다. 사실 스택에서 아무것도 저글링할 필요조차 없습니다. 리시버와 메서드 인수들은 이미 있어야 할 자리에 정확히 있습니다.
VM을 실행하고 메서드를 호출하는 작은 프로그램을 실행하면 이전과 똑같은 동작을 볼 수 있을 것입니다. 하지만 우리가 작업을 제대로 했다면, 성능은 크게 향상되었을 것입니다. 저는 10,000번의 메서드 호출을 일괄 처리하는 작은 마이크로벤치마크를 작성했습니다. 그런 다음 10초 동안 얼마나 많은 배치를 실행할 수 있는지 테스트했습니다. 제 컴퓨터에서 새로운 OP_INVOKE 명령이 없을 때는 1,089번의 배치를 처리했습니다. 이 새로운 최적화를 적용하자 같은 시간 동안 8,324번의 배치를 완료했습니다. 이는 7.6배 더 빠른 것으로, 프로그래밍 언어 최적화에 있어서 엄청난 개선입니다.
28 . 5 . 1필드 호출
최적화의 근본적인 신조는 "정확성을 해치지 말라"입니다. 사용자는 언어 구현이 더 빠르게 답을 제공할 때 좋아하지만, 그것이 올바른 답일 때만 그렇습니다. 아쉽게도, 더 빠른 메서드 호출을 위한 우리의 구현은 그 원칙을 지키지 못했습니다:
class Oops { init() { fun f() { print "not a method"; } this.field = f; } } var oops = Oops(); oops.field();
마지막 줄은 메서드 호출처럼 보입니다. 컴파일러는 그렇게 생각하고 충실히 OP_INVOKE 명령을 방출합니다. 하지만 실제로는 그렇지 않습니다. 실제로 발생하는 것은 함수를 반환하는 필드 접근이고, 그 함수가 호출되는 것입니다. 현재, 우리의 VM은 이를 올바르게 실행하는 대신 "field"라는 이름의 메서드를 찾을 수 없을 때 런타임 오류를 보고합니다.
이전에 OP_GET_PROPERTY를 구현할 때, 우리는 필드 접근과 메서드 접근을 모두 처리했습니다. 이 새로운 버그를 해결하기 위해 OP_INVOKE에 대해서도 동일한 작업을 수행해야 합니다.
ObjInstance* instance = AS_INSTANCE(receiver);
in invoke()
Value value; if (tableGet(&instance->fields, name, &value)) { vm.stackTop[-argCount - 1] = value; return callValue(value, argCount); }
return invokeFromClass(instance->klass, name, argCount);
꽤 간단한 수정입니다. 인스턴스의 클래스에서 메서드를 찾기 전에, 동일한 이름의 필드를 찾습니다. 필드를 찾으면, 리시버 대신 스택에, 인수 목록 아래에 저장합니다. 이는 OP_GET_PROPERTY가 후속 괄호로 묶인 인수 목록이 평가되기 전에 실행되기 때문에 그렇게 동작합니다.
그런 다음 해당 필드의 값을 호출 가능한 것으로(바라건대) 호출하려고 시도합니다. callValue() 헬퍼는 값의 타입을 확인하고 적절하게 호출하거나, 필드의 값이 클로저와 같은 호출 가능한 타입이 아니면 런타임 오류를 보고합니다.
이것이 우리 최적화를 완전히 안전하게 만드는 데 필요한 전부입니다. 아쉽게도 성능은 조금 희생됩니다. 하지만 때로는 감수해야 할 대가입니다. 언어가 성가신 예외 사례를 허용하지 않았다면 할 수 있었을 최적화에 대해 때때로 좌절감을 느낄 수 있습니다. 하지만 언어 구현자로서 우리는 주어진 게임을 해야 합니다.
우리가 여기서 작성한 코드는 일반적인 최적화 패턴을 따릅니다:
-
성능에 중요한 일반적인 연산 또는 연산 시퀀스를 인식합니다. 이 경우 메서드 접근 후 호출입니다.
-
해당 패턴에 대한 최적화된 구현을 추가합니다. 이것이 우리의
OP_INVOKE명령입니다. -
패턴이 실제로 적용되는지 유효성을 검사하는 조건부 논리로 최적화된 코드를 보호합니다. 적용되면 빠른 경로를 유지합니다. 그렇지 않으면 더 느리지만 더 견고한 최적화되지 않은 동작으로 폴백(fallback)합니다. 여기서는 실제로 메서드를 호출하는지, 필드에 접근하는지 확인하는 것을 의미합니다.
언어 작업이 "작동하는 것"에서 "더 빠르게 작동하는 것"으로 넘어가면서, 이와 같은 패턴을 찾고 보호된 최적화를 추가하는 데 점점 더 많은 시간을 할애하게 될 것입니다. 풀타임 VM 엔지니어는 경력의 대부분을 이 반복 과정에서 보냅니다.
하지만 지금은 여기서 멈출 수 있습니다. 이로써 clox는 객체 지향 프로그래밍 언어의 대부분 기능을 존중할 만한 성능으로 지원합니다.
도전 과제
-
클래스의
init()메서드를 찾기 위한 해시 테이블 조회는 상수 시간(constant time)이지만 여전히 상당히 느립니다. 더 빠른 것을 구현하세요. 벤치마크를 작성하고 성능 차이를 측정하세요. -
Lox와 같은 동적 타입 언어에서, 단일 호출 사이트(callsite)는 프로그램 실행 중에 여러 클래스에 대한 다양한 메서드를 호출할 수 있습니다. 그럼에도 불구하고, 실제로는 대부분의 경우 호출 사이트는 실행 기간 동안 항상 동일한 클래스의 정확히 동일한 메서드를 호출합니다. 언어적으로는 다형성을 허용하더라도, 대부분의 호출은 실제로는 다형적이지 않습니다.
고급 언어 구현은 이 관찰을 기반으로 어떻게 최적화할까요?
-
OP_INVOKE명령을 해석할 때, VM은 두 번의 해시 테이블 조회를 해야 합니다. 먼저 메서드를 가릴 수 있는 필드를 찾고, 그것이 실패해야만 메서드를 찾습니다. 전자의 검사는 거의 유용하지 않습니다. 대부분의 필드에는 함수가 포함되어 있지 않습니다. 하지만 언어적으로 필드와 메서드가 동일한 구문을 사용하여 접근되며, 필드가 메서드를 가린다고 명시되어 있기 때문에 이는 필수적입니다.이는 우리 구현의 성능에 영향을 미치는 언어 선택입니다. 올바른 선택이었을까요? Lox가 당신의 언어였다면, 어떻게 하시겠습니까?
디자인 노트: 신규 기능 예산
저는 TRS-80에서 작은 BASIC 프로그램을 처음 작성하고 컴퓨터가 이전에 하지 않던 일을 하게 만들었던 때를 여전히 기억합니다. 그것은 마치 초능력 같았습니다. 파서와 인터프리터를 충분히 엮어 제 언어로 작은 프로그램을 작성하여 컴퓨터가 어떤 일을 하게 만들었던 첫 경험은 일종의 고차원적인 메타 초능력 같았습니다. 그것은 놀라운 느낌이었고 지금도 그렇습니다.
저는 제가 선택한 대로 보이고 동작하는 언어를 설계할 수 있다는 것을 깨달았습니다. 마치 평생 교복을 입어야 하는 사립학교에 다니다가 어느 날 원하는 옷을 입을 수 있는 공립학교로 전학 온 것 같았습니다. 블록에 중괄호를 사용할 필요가 없다고요? 할당에 등호 외에 다른 것을 사용할 수 있다고요? 클래스 없이 객체를 만들 수 있다고요? 다중 상속과 다중 메서드를 모두 지원한다고요? 정적으로가 아니라 아리티(arity)로 오버로드되는 동적 언어라고요?
당연히 저는 그 자유를 만끽하며 달려나갔습니다. 저는 가장 이상하고 임의적인 언어 디자인 결정을 내렸습니다. 제네릭을 위한 아포스트로피. 인수 사이에 쉼표 없음. 런타임에 실패할 수 있는 오버로드 해상. 저는 단지 다름을 위해 다르게 했습니다.
이것은 제가 강력히 추천하는 매우 즐거운 경험입니다. 우리는 더 많은 이상하고 아방가르드한 프로그래밍 언어가 필요합니다. 더 많은 예술 언어를 보고 싶습니다. 저는 여전히 가끔 재미로 기발한 장난감 언어를 만듭니다.
하지만, 여러분의 목표가 "성공"이 많은 수의 사용자로 정의되는 것이라면, 여러분의 우선순위는 달라야 합니다. 이 경우, 여러분의 주요 목표는 여러분의 언어를 가능한 한 많은 사람들의 두뇌에 로드하는 것입니다. 그것은 정말 어렵습니다. 언어의 구문과 의미론을 컴퓨터에서 수조 개의 뉴런으로 옮기는 데는 많은 인간적인 노력이 필요합니다.
프로그래머들은 본질적으로 자신의 시간에 대해 보수적이며, 어떤 언어가 자신의 "웻웨어(wetware)"에 업로드할 가치가 있는지 조심스럽습니다. 그들은 자신에게 쓸모없는 언어에 시간을 낭비하고 싶어 하지 않습니다. 언어 설계자로서 여러분의 목표는 가능한 한 적은 학습으로 가능한 한 많은 언어적 힘을 그들에게 주는 것입니다.
한 가지 자연스러운 접근 방식은 단순성입니다. 여러분의 언어가 가진 개념과 기능이 적을수록, 총 학습량이 줄어듭니다. 이것이 최소한의 스크립팅 언어가 큰 산업 언어만큼 강력하지 않음에도 불구하고 종종 성공을 거두는 이유 중 하나입니다. 배우기 시작하기가 더 쉽고, 일단 사람들의 머릿속에 들어가면 사용자는 계속 사용하고 싶어 합니다.
단순성의 문제는 단순히 기능을 줄이는 것이 종종 기능과 표현력을 희생시킨다는 점입니다. 가치 있는 기능을 찾는 기술이 있지만, 종종 최소한의 언어는 단순히 할 수 있는 것이 더 적습니다.
이 문제의 상당 부분을 피하는 또 다른 경로가 있습니다. 비결은 사용자가 여러분의 언어 전체를 머릿속에 로드할 필요가 없고, 이미 가지고 있지 않은 부분만 로드하면 된다는 것을 깨닫는 것입니다. 제가 이전 디자인 노트에서 언급했듯이, 학습은 이미 알고 있는 것과 알아야 할 것 사이의 차이(delta)를 옮기는 것입니다.
여러분의 언어의 많은 잠재적 사용자들은 이미 다른 프로그래밍 언어를 알고 있습니다. 여러분의 언어가 그 언어와 공유하는 모든 기능은 학습 측면에서 본질적으로 "무료"입니다. 그것은 이미 그들의 머릿속에 있으며, 단지 여러분의 언어가 같은 일을 한다는 것을 인식하기만 하면 됩니다.
즉, 익숙함은 여러분의 언어의 도입 비용을 낮추는 또 다른 핵심 도구입니다. 물론, 이 속성을 완전히 극대화한다면, 최종 결과는 기존의 어떤 언어와 완전히 동일한 언어가 될 것입니다. 그것은 성공을 위한 레시피가 아닙니다. 왜냐하면 그 시점에는 사용자가 여러분의 언어로 전환할 유인이 전혀 없기 때문입니다.
따라서 설득력 있는 몇 가지 차이점을 제공해야 합니다. 여러분의 언어가 다른 언어는 할 수 없거나, 적어도 그만큼 잘 할 수 없는 몇 가지. 저는 이것이 언어 디자인의 근본적인 균형 잡기 중 하나라고 생각합니다. 다른 언어와의 유사성은 학습 비용을 낮추는 반면, 차이점은 설득력 있는 장점을 높입니다.
저는 이 균형 잡기를 신규 기능 예산(novelty budget), 또는 스티브 클라브닉(Steve Klabnik)이 부르는 "이상함 예산(strangeness budget)"이라는 용어로 생각합니다. 사용자들은 새로운 언어를 배우기 위해 기꺼이 받아들일 수 있는 새로운 것의 총량에 대한 낮은 임계값을 가지고 있습니다. 이를 초과하면 그들은 나타나지 않을 것입니다.
여러분이 언어에 다른 언어에는 없는 새로운 것을 추가하거나, 여러분의 언어가 다른 언어와 다른 방식으로 무언가를 할 때마다, 그 예산의 일부를 소비하게 됩니다. 괜찮습니다. 여러분의 언어를 매력적으로 만들기 위해 그 예산을 소비해야 합니다. 하지만 여러분의 목표는 그것을 현명하게 소비하는 것입니다. 각 기능이나 차이점에 대해, 그것이 여러분의 언어에 얼마나 많은 매력적인 힘을 추가하는지 자문하고, 그것이 가치가 있는지 비판적으로 평가하십시오. 그 변화가 너무나 가치 있어서 신규 기능 예산의 일부를 소진할 만한 가치가 있을까요?
실제로 저는 이것이 구문에는 상당히 보수적이고 의미론에는 더 모험적인 태도를 취하게 된다는 것을 발견했습니다. 새 옷으로 갈아입는 것이 재미있긴 하지만, 중괄호를 다른 블록 구분자로 바꾸는 것은 언어에 실제적인 힘을 거의 추가하지 않을 것이지만, 신규 기능 예산을 소비합니다. 구문적 차이점이 그 가치를 지니기 어렵습니다.
반면에 새로운 의미론은 언어의 힘을 크게 증가시킬 수 있습니다. 다중 메서드, 믹스인, 트레이트, 리플렉션, 의존형, 런타임 메타 프로그래밍 등은 사용자가 언어로 할 수 있는 것을 근본적으로 향상시킬 수 있습니다.
아쉽게도, 이처럼 보수적인 것은 모든 것을 바꾸는 것만큼 재미있지는 않습니다. 하지만 처음부터 주류의 성공을 쫓을지 말지는 여러분에게 달려 있습니다. 우리 모두가 라디오 친화적인 팝 밴드가 될 필요는 없습니다. 여러분의 언어가 자유 재즈나 드론 메탈 같고, 비례적으로 작지만 (아마도 더 헌신적인) 청중 규모에 만족한다면, 그렇게 하세요.