29

상위 클래스

친구는 고를 수 있지만, 가족은 고를 수 없어. 네가 인정하든 안 하든 그들은 여전히 네 혈육이고, 그러지 않으면 정말 어리석어 보인단다.

하퍼 리, 앵무새 죽이기

이번 장은 VM에 새로운 기능을 추가하는 마지막 장입니다. 우리는 거의 모든 Lox 언어를 이미 VM에 담아냈습니다. 이제 남은 것은 메서드 상속과 상위 클래스 메서드 호출뿐입니다. 이 다음에는 또 다른 장이 있지만, 새로운 동작을 추가하는 것은 아닙니다. 기존 기능을 더 빠르게 만드는 것에 불과합니다. 이번 장을 마치면 완벽한 Lox 구현을 갖게 될 것입니다.

이번 장의 일부 내용은 jlox를 떠올리게 할 것입니다. super 호출을 해결하는 방식은 clox의 더 복잡한 스택 상태 저장 메커니즘을 통해 살펴보더라도 거의 동일합니다. 하지만 이번에는 상속된 메서드 호출을 처리하는 완전히 다르면서 훨씬 빠른 방법을 사용합니다.

29 . 1메서드 상속

더 간단한 부분부터 시작하기 위해 메서드 상속부터 다루겠습니다. 기억을 되살리기 위해, Lox의 상속 문법은 다음과 같습니다.

class Doughnut {
  cook() {
    print "Dunk in the fryer.";
  }
}

class Cruller < Doughnut {
  finish() {
    print "Glaze with icing.";
  }
}

여기서 Cruller 클래스는 Doughnut을 상속하므로, Cruller의 인스턴스는 cook() 메서드를 상속받습니다. 제가 왜 이런 것을 장황하게 설명하는지 모르겠습니다. 여러분은 상속이 어떻게 작동하는지 잘 아실 겁니다. 이제 새로운 문법을 컴파일하기 시작합시다.

  currentClass = &classCompiler;

compiler.c
in classDeclaration()
  if (match(TOKEN_LESS)) {
    consume(TOKEN_IDENTIFIER, "상위 클래스 이름이 필요합니다.");
    variable(false);
    namedVariable(className, false);
    emitByte(OP_INHERIT);
  }

  namedVariable(className, false);
compiler.c, in classDeclaration()

클래스 이름을 컴파일한 후, 다음 토큰이 <이면 상위 클래스 절을 찾은 것입니다. 상위 클래스의 식별자 토큰을 소비한 다음 variable()을 호출합니다. 이 함수는 이전에 소비된 토큰을 변수 참조로 처리하고, 변수의 값을 로드하는 코드를 생성합니다. 즉, 이름으로 상위 클래스를 찾아 스택에 푸시합니다.

그 후, namedVariable()을 호출하여 상속하는 하위 클래스를 스택에 로드하고, 이어서 OP_INHERIT 명령어를 추가합니다. 이 명령어는 상위 클래스를 새로운 하위 클래스에 연결합니다. 지난 장에서는 기존 클래스 객체에 메서드 테이블에 메서드를 추가하여 변경하는 OP_METHOD 명령어를 정의했습니다. 이와 비슷하게—OP_INHERIT 명령어는 기존 클래스를 가져와 상속의 효과를 적용합니다.

이전 예제에서 컴파일러가 다음 구문 조각을 처리할 때:

class Cruller < Doughnut {

그 결과는 다음과 같은 바이트코드입니다:

Doughnut을 상속하는 Cruller 클래스에 대한 일련의 바이트코드 명령어입니다.

새로운 OP_INHERIT 명령어를 구현하기 전에, 예외적인 경우(edge case)를 감지해야 합니다.

    variable(false);
compiler.c
in classDeclaration()

    if (identifiersEqual(&className, &parser.previous)) {
      error("클래스는 자기 자신을 상속할 수 없습니다.");
    }

    namedVariable(className, false);
compiler.c, in classDeclaration()

클래스는 자기 자신의 상위 클래스가 될 수 없습니다. 정신 나간 핵 물리학자와 고도로 개조된 드로리안(DeLorean)을 얻지 못하는 한, 자기 자신을 상속할 수는 없습니다.

29 . 1 . 1상속 실행하기

이제 새로운 명령어로 넘어갑니다.

  OP_CLASS,
chunk.h
in enum OpCode
  OP_INHERIT,
  OP_METHOD
chunk.h, in enum OpCode

걱정할 피연산자는 없습니다. 필요한 두 값, 즉 상위 클래스와 하위 클래스 모두 스택에 있습니다. 이는 역어셈블(disassembling)이 쉽다는 것을 의미합니다.

      return constantInstruction("OP_CLASS", chunk, offset);
debug.c
in disassembleInstruction()
    case OP_INHERIT:
      return simpleInstruction("OP_INHERIT", offset);
    case OP_METHOD:
debug.c, in disassembleInstruction()

인터프리터에서 실제 동작이 이루어집니다.

        break;
vm.c
in run()
      case OP_INHERIT: {
        Value superclass = peek(1);
        ObjClass* subclass = AS_CLASS(peek(0));
        tableAddAll(&AS_CLASS(superclass)->methods,
                    &subclass->methods);
        pop(); // 하위 클래스.
        break;
      }
      case OP_METHOD:
vm.c, in run()

스택의 맨 위부터 아래로 하위 클래스, 그 다음 상위 클래스가 있습니다. 우리는 두 값을 모두 가져와서 상속 관련 작업을 수행합니다. 여기서 clox는 jlox와 다른 경로를 택합니다. 첫 번째 인터프리터에서 각 하위 클래스는 상위 클래스에 대한 참조를 저장했습니다. 메서드 접근 시, 하위 클래스의 메서드 테이블에서 메서드를 찾지 못하면 상속 체인을 통해 각 조상의 메서드 테이블을 재귀적으로 탐색하여 찾았습니다.

예를 들어, Cruller 인스턴스에서 cook()을 호출하면 jlox는 다음 경로를 따릅니다:

Cruller 인스턴스에서 cook()을 호출하면 메서드를 해결하기 위해 상위 클래스 체인을 탐색해야 합니다.

이는 메서드 호출 시점에 수행해야 할 많은 작업입니다. 느리고, 더 나쁜 것은 상속된 메서드가 조상 체인에서 멀리 있을수록 더 느려진다는 점입니다. 좋은 성능 이야기는 아닙니다.

새로운 접근 방식은 훨씬 빠릅니다. 하위 클래스가 선언될 때, 상속받은 클래스의 모든 메서드를 하위 클래스 자체의 메서드 테이블로 복사합니다. 나중에 메서드를 호출할 때, 상위 클래스에서 상속받은 모든 메서드는 하위 클래스 자체의 메서드 테이블에서 바로 찾아집니다. 상속을 위해 추가적인 런타임 작업이 전혀 필요 없습니다. 클래스가 선언되는 시점에 작업이 완료되는 것입니다. 이는 상속된 메서드 호출이 일반 메서드 호출만큼 빠르다는 것을 의미합니다—단일 해시 테이블 조회만 필요합니다.

Cruller 인스턴스에서 cook()을 호출하면 해당 메서드가 자체 메서드 테이블에 있으므로 바로 해결됩니다.

저는 가끔 이 기법을 “하향 복사 상속(copy-down inheritance)”이라고 부르는 것을 들었습니다. 이는 간단하고 빠르지만, 대부분의 최적화와 마찬가지로 특정 제약 조건 하에서만 사용할 수 있습니다. Lox에서는 Lox 클래스가 닫혀있기 때문에 이 방식이 작동합니다. 클래스 선언 실행이 완료되면 해당 클래스의 메서드 집합은 절대로 변경될 수 없습니다.

Ruby, Python, JavaScript와 같은 언어에서는 기존 클래스를 열어서 새로운 메서드를 추가하거나 심지어 제거할 수 있습니다. 이는 우리의 최적화를 깨뜨릴 것입니다. 만약 하위 클래스 선언이 실행된 에 상위 클래스에 대한 변경이 발생하면, 하위 클래스는 그러한 변경을 반영하지 못할 것이기 때문입니다. 이는 상속이 항상 상위 클래스의 현재 상태를 반영할 것이라는 사용자의 기대를 저버리는 것입니다.

다행히 (이 기능을 좋아하는 사용자들에게는 아쉽겠지만) Lox는 몽키 패칭이나 덕 펀칭을 허용하지 않으므로, 우리는 이 최적화를 안전하게 적용할 수 있습니다.

메서드 오버라이드는 어떻게 될까요? 상위 클래스의 메서드를 하위 클래스의 메서드 테이블로 복사하는 것이 하위 클래스 자체의 메서드와 충돌하지 않을까요? 다행히 아닙니다. 우리는 하위 클래스를 생성하는 OP_CLASS 명령어 이후에 OP_INHERIT를 생성하지만, 메서드 선언 및 OP_METHOD 명령어가 컴파일되기 전입니다. 상위 클래스의 메서드를 복사하는 시점에는 하위 클래스의 메서드 테이블이 비어 있습니다. 하위 클래스가 오버라이드하는 모든 메서드는 테이블에 있는 상속된 항목들을 덮어쓰게 됩니다.

29 . 1 . 2유효하지 않은 상위 클래스

저의 VM 코드가 그렇듯이, 저희 구현은 간단하고 빠릅니다. 하지만 견고하지는 않습니다. 사용자가 클래스가 아닌 객체로부터 상속하는 것을 막는 것이 없습니다:

var NotClass = "So not a class";
class OhNo < NotClass {}

명백히, 자존감 있는 프로그래머라면 저런 코드를 작성하지 않겠지만, 우리는 자존감 없는 잠재적인 Lox 사용자들을 대비해야 합니다. 간단한 런타임 검사로 이 문제를 해결할 수 있습니다.

        Value superclass = peek(1);
vm.c
in run()
        if (!IS_CLASS(superclass)) {
          runtimeError("상위 클래스는 클래스여야 합니다.");
          return INTERPRET_RUNTIME_ERROR;
        }

        ObjClass* subclass = AS_CLASS(peek(0));
vm.c, in run()

상위 클래스 절의 식별자에서 로드한 값이 ObjClass가 아니라면, 우리는 런타임 에러를 보고하여 사용자에게 그들의 코드에 대한 우리의 생각을 알려줍니다.

29 . 2상위 클래스 저장하기

메서드 상속을 추가할 때, 하위 클래스에서 상위 클래스로의 참조를 실제로 추가하지 않았다는 것을 눈치채셨나요? 상속된 메서드를 복사한 후에는 상위 클래스를 완전히 잊어버립니다. 상위 클래스를 계속 유지할 필요가 없으므로 그렇게 하지 않습니다.

그것만으로는 super 호출을 지원하기에 충분하지 않습니다. 하위 클래스가 상위 클래스 메서드를 오버라이드할 수 있으므로, 우리는 상위 클래스 메서드 테이블에 접근할 수 있어야 합니다. 이 메커니즘을 다루기 전에, super 호출이 정적으로 어떻게 해결되는지 기억을 되살려보고 싶습니다.

jlox의 평온했던 시절로 돌아가, super 호출이 어떻게 디스패치되는지 설명하기 위해 이 까다로운 예제를 보여드렸습니다:

class A {
  method() {
    print "A method";
  }
}

class B < A {
  method() {
    print "B method";
  }

  test() {
    super.method();
  }
}

class C < B {}

C().test();

test() 메서드 본문 안에서 this는 C의 인스턴스입니다. 만약 super 호출이 리시버(receiver)의 상위 클래스를 기준으로 해결된다면, 우리는 C의 상위 클래스인 B에서 찾을 것입니다. 하지만 super 호출은 super 호출이 발생하는 주변 클래스의 상위 클래스를 기준으로 해결됩니다. 이 경우, 우리는 B의 test() 메서드 안에 있으므로, 상위 클래스는 A이고 프로그램은 “A method”를 출력해야 합니다.

이는 super 호출이 런타임 인스턴스를 기반으로 동적으로 해결되지 않는다는 의미입니다. 메서드를 찾기 위해 사용되는 상위 클래스는 호출이 발생하는 위치의 정적—사실상 렉시컬(lexical)—속성입니다. jlox에 상속을 추가했을 때, 우리는 모든 렉시컬 스코프에 사용했던 동일한 Environment 구조에 상위 클래스를 저장함으로써 그 정적인 측면을 활용했습니다. 마치 인터프리터가 위의 프로그램을 다음과 같이 본 것과 같습니다:

class A {
  method() {
    print "A method";
  }
}

var Bs_super = A;
class B < A {
  method() {
    print "B method";
  }

  test() {
    runtimeSuperCall(Bs_super, "method");
  }
}

var Cs_super = B;
class C < B {}

C().test();

각 하위 클래스는 상위 클래스에 대한 참조를 저장하는 숨겨진 변수를 가집니다. super 호출을 수행해야 할 때마다, 우리는 그 변수에서 상위 클래스에 접근하고 런타임에게 거기서부터 메서드를 찾기 시작하도록 지시합니다.

clox에서도 동일한 경로를 따를 것입니다. 차이점은 jlox의 힙 할당(heap-allocated) Environment 클래스 대신, 바이트코드 VM의 값 스택과 업밸류(upvalue) 시스템을 사용한다는 것입니다. 메커니즘은 조금 다르지만, 전체적인 효과는 동일합니다.

29 . 2 . 1상위 클래스 지역 변수

우리 컴파일러는 이미 상위 클래스를 스택에 로드하는 코드를 생성합니다. 그 슬롯을 임시로 두는 대신, 새로운 스코프를 생성하고 이를 지역 변수로 만듭니다.

    }

compiler.c
in classDeclaration()
    beginScope();
    addLocal(syntheticToken("super"));
    defineVariable(0);

    namedVariable(className, false);
    emitByte(OP_INHERIT);
compiler.c, in classDeclaration()

새로운 렉시컬 스코프를 생성하는 것은 동일한 스코프 내에서 두 개의 클래스를 선언하더라도 각각이 상위 클래스를 저장할 다른 지역 슬롯을 갖도록 보장합니다. 이 변수의 이름을 항상 “super”로 지정하므로, 각 하위 클래스에 대한 스코프를 만들지 않으면 변수들이 충돌할 것입니다.

우리는 this 표현식이 해결되는 숨겨진 지역 변수 이름으로 “this”를 사용하는 것과 같은 이유로 변수 이름을 “super”로 지정합니다. “super”는 예약어이므로, 컴파일러의 숨겨진 변수가 사용자 정의 변수와 충돌하지 않음을 보장합니다.

차이점은 this 표현식을 컴파일할 때는 렉심이 “this”인 토큰이 마침 존재한다는 것입니다. 여기서는 그렇게 운이 좋지 않습니다. 대신, 주어진 상수 문자열에 대한 합성 토큰(synthetic token)을 생성하는 작은 헬퍼 함수를 추가합니다.

compiler.c
add after variable()
static Token syntheticToken(const char* text) {
  Token token;
  token.start = text;
  token.length = (int)strlen(text);
  return token;
}
compiler.c, add after variable()

상위 클래스 변수를 위해 지역 스코프를 열었으므로, 이를 닫아야 합니다.

  emitByte(OP_POP);
compiler.c
in classDeclaration()

  if (classCompiler.hasSuperclass) {
    endScope();
  }

  currentClass = currentClass->enclosing;
compiler.c, in classDeclaration()

클래스 본문과 메서드를 컴파일한 후 스코프를 팝(pop)하고 “super” 변수를 버립니다. 그렇게 하면 해당 변수는 하위 클래스의 모든 메서드에서 접근할 수 있습니다. 다소 무의미한 최적화이지만, 상위 클래스 절이 있을 때만 스코프를 생성합니다. 따라서 상위 클래스 절이 있을 때만 스코프를 닫아야 합니다.

이를 추적하기 위해 classDeclaration()에 작은 지역 변수를 선언할 수도 있습니다. 하지만 곧 컴파일러의 다른 함수들이 주변 클래스가 하위 클래스인지 아닌지 알아야 할 것입니다. 그러므로 지금 ClassCompiler에 이 사실을 필드로 저장하여 나중을 위해 미리 준비하는 것이 좋겠습니다.

typedef struct ClassCompiler {
  struct ClassCompiler* enclosing;
compiler.c
in struct ClassCompiler
  bool hasSuperclass;
} ClassCompiler;
compiler.c, in struct ClassCompiler

ClassCompiler를 처음 초기화할 때, 우리는 그것이 하위 클래스가 아니라고 가정합니다.

  ClassCompiler classCompiler;
compiler.c
in classDeclaration()
  classCompiler.hasSuperclass = false;
  classCompiler.enclosing = currentClass;
compiler.c, in classDeclaration()

그 후, 상위 클래스 절을 발견하면 우리는 하위 클래스를 컴파일 중임을 알 수 있습니다.

    emitByte(OP_INHERIT);
compiler.c
in classDeclaration()
    classCompiler.hasSuperclass = true;
  }
compiler.c, in classDeclaration()

이 메커니즘은 런타임에 주변 하위 클래스의 상위 클래스 객체에 하위 클래스의 모든 메서드 내부에서 접근할 수 있는 방법을 제공합니다—단순히 “super”라는 이름의 변수를 로드하는 코드를 생성하기만 하면 됩니다. 이 변수는 메서드 본문 외부의 지역 변수이지만, 기존의 업밸류(upvalue) 지원을 통해 VM은 이 지역 변수를 메서드 본문 내부나 심지어 그 메서드 안에 중첩된 함수에서도 캡처할 수 있습니다.

29 . 3super 호출

런타임 지원이 갖춰졌으므로, 이제 super 호출을 구현할 준비가 되었습니다. 늘 그렇듯이, 새로운 문법부터 시작하여 처음부터 끝까지 살펴보겠습니다. super 호출은 당연히 super 키워드로 시작합니다.

  [TOKEN_RETURN]        = {NULL,     NULL,   PREC_NONE},
compiler.c
replace 1 line
  [TOKEN_SUPER]         = {super_,   NULL,   PREC_NONE},
  [TOKEN_THIS]          = {this_,    NULL,   PREC_NONE},
compiler.c, replace 1 line

표현식 파서가 super 토큰을 만나면, 제어는 다음과 같이 시작되는 새로운 파싱 함수로 점프합니다:

compiler.c
add after syntheticToken()
static void super_(bool canAssign) {
  consume(TOKEN_DOT, "super' 뒤에 '.'이 필요합니다.");
  consume(TOKEN_IDENTIFIER, "상위 클래스 메서드 이름이 필요합니다.");
  uint8_t name = identifierConstant(&parser.previous);
}
compiler.c, add after syntheticToken()

이는 this 표현식을 컴파일했던 방식과는 꽤 다릅니다. this와 달리, super 토큰은 독립적인 표현식이 아닙니다. 대신, 그 뒤에 오는 점(.)과 메서드 이름은 구문의 분리할 수 없는 부분입니다. 하지만 괄호로 묶인 인자 목록은 별개입니다. 일반적인 메서드 접근과 마찬가지로, Lox는 호출하지 않고도 클로저로서 상위 클래스 메서드에 대한 참조를 얻는 것을 지원합니다:

class A {
  method() {
    print "A";
  }
}

class B < A {
  method() {
    var closure = super.method;
    closure(); // "A"를 출력합니다.
  }
}

다시 말해, Lox에는 실제 super 호출 표현식은 없고, super 접근 표현식이 있으며, 원한다면 즉시 호출할 수 있습니다. 따라서 컴파일러가 super 토큰을 만나면, 우리는 그 뒤에 오는 . 토큰을 소비하고 메서드 이름을 찾습니다. 메서드는 동적으로 조회되므로, 프로퍼티 접근 표현식에서와 마찬가지로 identifierConstant()를 사용하여 메서드 이름 토큰의 렉심을 가져와 상수 테이블에 저장합니다.

컴파일러가 이 토큰들을 소비한 후에 하는 일은 다음과 같습니다:

  uint8_t name = identifierConstant(&parser.previous);
compiler.c
in super_()

  namedVariable(syntheticToken("this"), false);
  namedVariable(syntheticToken("super"), false);
  emitBytes(OP_GET_SUPER, name);
}
compiler.c, in super_()

현재 인스턴스에서 상위 클래스 메서드에 접근하기 위해, 런타임은 리시버 주변 메서드의 클래스에 대한 상위 클래스 모두를 필요로 합니다. 첫 번째 namedVariable() 호출은 숨겨진 변수 “this”에 저장된 현재 리시버를 찾아 스택에 푸시하는 코드를 생성합니다. 두 번째 namedVariable() 호출은 “super” 변수에서 상위 클래스를 찾아 스택 맨 위에 푸시하는 코드를 생성합니다.

마지막으로, 메서드 이름의 상수 테이블 인덱스에 대한 피연산자를 포함하는 새로운 OP_GET_SUPER 명령어를 생성합니다. 이것은 머릿속에 담기에는 많은 내용입니다. 이해를 돕기 위해 다음 예제 프로그램을 살펴보세요:

class Doughnut {
  cook() {
    print "Dunk in the fryer.";
    this.finish("sprinkles");
  }

  finish(ingredient) {
    print "Finish with " + ingredient;
  }
}

class Cruller < Doughnut {
  finish(ingredient) {
    // 뿌리는 재료는 없이, 항상 아이싱만.
    super.finish("icing");
  }
}

super.finish("icing") 표현식에 대해 생성된 바이트코드는 다음과 같이 작동합니다:

super.finish()를 호출하기 위한 일련의 바이트코드 명령어입니다.

처음 세 가지 명령어는 super 접근을 수행하기 위해 런타임에 필요한 세 가지 정보에 대한 접근을 제공합니다:

  1. 첫 번째 명령어는 인스턴스를 스택에 로드합니다.

  2. 두 번째 명령어는 메서드가 해결되는 상위 클래스를 로드합니다.

  3. 그 다음 새로운 OP_GET_SUPER 명령어는 피연산자로 접근할 메서드의 이름을 인코딩합니다.

나머지 명령어는 인자 목록을 평가하고 함수를 호출하기 위한 일반적인 바이트코드입니다.

이제 인터프리터에 새로운 OP_GET_SUPER 명령어를 구현할 준비가 거의 되었습니다. 하지만 그 전에, 컴파일러가 보고해야 할 몇 가지 에러가 있습니다.

static void super_(bool canAssign) {
compiler.c
in super_()
  if (currentClass == NULL) {
    error("클래스 외부에서는 'super'를 사용할 수 없습니다.");
  } else if (!currentClass->hasSuperclass) {
    error("상위 클래스가 없는 클래스에서는 'super'를 사용할 수 없습니다.");
  }

  consume(TOKEN_DOT, "Expect '.' after 'super'.");
compiler.c, in super_()

super 호출은 메서드 본문 내부(또는 메서드 안에 중첩된 함수 내부)에서만 의미가 있으며, 상위 클래스가 있는 클래스의 메서드 내부에서만 의미가 있습니다. 우리는 currentClass의 값을 사용하여 이 두 가지 경우를 모두 감지합니다. 만약 currentClassNULL이거나 상위 클래스가 없는 클래스를 가리킨다면, 해당 에러를 보고합니다.

29 . 3 . 1super 접근 실행하기

사용자가 허용되지 않는 곳에 super 표현식을 넣지 않았다고 가정하면, 그들의 코드는 컴파일러에서 런타임으로 넘어갑니다. 새로운 명령어가 생겼습니다.

  OP_SET_PROPERTY,
chunk.h
in enum OpCode
  OP_GET_SUPER,
  OP_EQUAL,
chunk.h, in enum OpCode

상수 테이블 인덱스 피연산자를 취하는 다른 옵코드(opcode)와 마찬가지로 이를 역어셈블합니다.

      return constantInstruction("OP_SET_PROPERTY", chunk, offset);
debug.c
in disassembleInstruction()
    case OP_GET_SUPER:
      return constantInstruction("OP_GET_SUPER", chunk, offset);
    case OP_EQUAL:
debug.c, in disassembleInstruction()

더 어려울 것이라고 예상할 수도 있지만, 새로운 명령어를 해석하는 것은 일반적인 프로퍼티 접근을 실행하는 것과 유사합니다.

      }
vm.c
in run()
      case OP_GET_SUPER: {
        ObjString* name = READ_STRING();
        ObjClass* superclass = AS_CLASS(pop());

        if (!bindMethod(superclass, name)) {
          return INTERPRET_RUNTIME_ERROR;
        }
        break;
      }
      case OP_EQUAL: {
vm.c, in run()

프로퍼티와 마찬가지로, 메서드 이름을 상수 테이블에서 읽어옵니다. 그런 다음 이 이름을 bindMethod()에 전달하고, 이 함수는 주어진 클래스의 메서드 테이블에서 메서드를 찾아 결과 클로저를 현재 인스턴스에 묶는 ObjBoundMethod를 생성합니다.

핵심적인 차이점은 bindMethod()어떤 클래스를 전달하느냐는 것입니다. 일반적인 프로퍼티 접근에서는 ObjInstance 자체의 클래스를 사용하여 원하는 동적 디스패치를 얻습니다. super 호출의 경우, 인스턴스의 클래스를 사용하지 않습니다. 대신, 포함하는 클래스의 정적으로 해결된 상위 클래스를 사용하는데, 컴파일러가 이 상위 클래스가 스택 맨 위에 기다리고 있도록 편리하게 보장해 두었습니다.

우리는 그 상위 클래스를 팝(pop)하고 bindMethod()에 전달합니다. 이 함수는 그 상위 클래스와 인스턴스 자체 클래스 사이에 있는 어떤 하위 클래스의 오버라이딩 메서드도 올바르게 건너뛰어 처리합니다. 또한 상위 클래스가 자신의 상위 클래스로부터 상속받은 모든 메서드를 올바르게 포함합니다.

나머지 동작은 동일합니다. 상위 클래스를 팝하면 인스턴스가 스택 맨 위에 남습니다. bindMethod()가 성공하면 인스턴스를 팝하고 새로운 바운드 메서드를 푸시합니다. 그렇지 않으면 런타임 에러를 보고하고 false를 반환합니다. 이 경우 우리는 인터프리터를 중단합니다.

29 . 3 . 2더 빠른 super 호출

이제 상위 클래스 메서드 접근이 작동합니다. 그리고 반환되는 객체가 호출할 수 있는 ObjBoundMethod이므로, super 호출도 작동합니다. 지난 장과 마찬가지로, 우리 VM이 완전하고 올바른 의미론을 갖는 지점에 도달했습니다.

하지만 지난 장과 마찬가지로, 이는 꽤 느립니다. 다시 말해, 우리는 각 super 호출마다 ObjBoundMethod를 힙 할당(heap allocating)하고 있는데, 대부분의 경우 다음 명령어가 즉시 그 바운드 메서드를 해제하고 호출한 다음 버리는 OP_CALL이기 때문입니다. 사실, 일반 메서드 호출보다 super 호출에서 이런 경우가 더 흔합니다. 메서드 호출의 경우 사용자가 필드에 저장된 함수를 실제로 호출할 가능성이 있습니다. super 호출의 경우, 당신은 항상 메서드를 찾고 있습니다. 유일한 질문은 그것을 즉시 호출할 것인가 아닌가 하는 것입니다.

상위 클래스 메서드 이름 뒤에 왼쪽 괄호가 보인다면 컴파일러는 이 질문에 스스로 답할 수 있으므로, 메서드 호출에 대해 수행했던 것과 동일한 최적화를 진행할 것입니다. 상위 클래스를 로드하고 OP_GET_SUPER를 생성하는 두 줄의 코드를 제거하고 다음으로 대체합니다:

  namedVariable(syntheticToken("this"), false);
compiler.c
in super_()
replace 2 lines
  if (match(TOKEN_LEFT_PAREN)) {
    uint8_t argCount = argumentList();
    namedVariable(syntheticToken("super"), false);
    emitBytes(OP_SUPER_INVOKE, name);
    emitByte(argCount);
  } else {
    namedVariable(syntheticToken("super"), false);
    emitBytes(OP_GET_SUPER, name);
  }
}
compiler.c, in super_(), replace 2 lines

이제 어떤 것을 생성하기 전에, 우리는 괄호로 묶인 인자 목록을 찾습니다. 만약 찾으면 그것을 컴파일합니다. 그리고 상위 클래스를 로드합니다. 그 후, 새로운 OP_SUPER_INVOKE 명령어를 생성합니다. 이 초특급 명령어(superinstruction)OP_GET_SUPEROP_CALL의 동작을 결합한 것이므로, 두 개의 피연산자를 취합니다: 조회할 메서드 이름의 상수 테이블 인덱스와 전달할 인자의 수입니다.

그렇지 않고 (를 찾지 못하면, 이전처럼 super 접근으로 표현식을 컴파일하고 OP_GET_SUPER를 생성합니다.

컴파일 파이프라인을 따라 내려가면, 첫 번째로 새로운 명령어에 도착합니다.

  OP_INVOKE,
chunk.h
in enum OpCode
  OP_SUPER_INVOKE,
  OP_CLOSURE,
chunk.h, in enum OpCode

그 직후에는 역어셈블러 지원이 있습니다.

      return invokeInstruction("OP_INVOKE", chunk, offset);
debug.c
in disassembleInstruction()
    case OP_SUPER_INVOKE:
      return invokeInstruction("OP_SUPER_INVOKE", chunk, offset);
    case OP_CLOSURE: {
debug.c, in disassembleInstruction()

super 호출 명령어는 OP_INVOKE와 동일한 피연산자 집합을 가지므로, 동일한 헬퍼 함수를 재사용하여 역어셈블합니다. 마지막으로, 파이프라인은 우리를 인터프리터로 이동시킵니다.

        break;
      }
vm.c
in run()
      case OP_SUPER_INVOKE: {
        ObjString* method = READ_STRING();
        int argCount = READ_BYTE();
        ObjClass* superclass = AS_CLASS(pop());
        if (!invokeFromClass(superclass, method, argCount)) {
          return INTERPRET_RUNTIME_ERROR;
        }
        frame = &vm.frames[vm.frameCount - 1];
        break;
      }
      case OP_CLOSURE: {
vm.c, in run()

이 몇 줄의 코드는 기본적으로 OP_INVOKE 구현에 OP_GET_SUPER를 약간 섞은 것입니다. 하지만 스택이 구성되는 방식에는 약간의 차이가 있습니다. 최적화되지 않은 super 호출의 경우, 호출의 인자가 실행되기 에 상위 클래스가 팝(pop)되고 해결된 함수에 대한 ObjBoundMethod로 대체됩니다. 이는 OP_CALL이 실행될 때, 바운드 메서드가 런타임이 클로저 호출에 대해 예상하는 위치인 인자 목록 아래에 있도록 보장합니다.

최적화된 명령어에서는 상황이 약간 뒤섞입니다:

OP_SUPER_INVOKE를 사용하여 super.finish()를 호출하기 위한 일련의 바이트코드 명령어입니다.

이제 상위 클래스 메서드를 해결하는 것이 호출의 일부가 되었으므로, 메서드를 조회하는 시점에는 인자들이 이미 스택에 있어야 합니다. 이는 상위 클래스 객체가 인자들 위에 있다는 것을 의미합니다.

그 외에는, 동작이 OP_GET_SUPER 뒤에 OP_CALL이 오는 것과 거의 같습니다. 먼저, 메서드 이름과 인자 개수 피연산자를 꺼냅니다. 그런 다음 스택 맨 위에서 상위 클래스를 팝하여 해당 메서드 테이블에서 메서드를 찾을 수 있도록 합니다. 이렇게 하면 메서드 호출에 필요한 스택이 편리하게 준비됩니다.

상위 클래스, 메서드 이름, 인자 개수를 기존의 invokeFromClass() 함수에 전달합니다. 이 함수는 주어진 클래스에서 주어진 메서드를 찾아 지정된 아리티(arity)로 호출을 생성하려고 시도합니다. 메서드를 찾을 수 없으면 false를 반환하고, 우리는 인터프리터를 종료합니다. 그렇지 않으면 invokeFromClass()는 메서드의 클로저를 위해 새로운 CallFrame을 호출 스택에 푸시합니다. 이는 인터프리터의 캐시된 CallFrame 포인터를 무효화하므로, 우리는 frame을 새로 고칩니다.

29 . 4완전한 가상 머신

우리가 만든 것을 되돌아봅시다. 제가 세어본 바에 따르면, 우리는 약 2,500줄의 상당히 깔끔하고 직관적인 C 코드를 작성했습니다. 이 작은 프로그램은 모든 우선순위 테이블로 가득 찬 표현식 타입과 다양한 제어 흐름문들을 갖춘—꽤 고수준의!—Lox 언어의 완전한 구현을 포함합니다. 우리는 변수, 함수, 클로저, 클래스, 필드, 메서드, 그리고 상속을 구현했습니다.

더욱 인상적인 것은, 우리 구현이 C 컴파일러가 있는 모든 플랫폼으로 이식 가능하며, 실제 프로덕션 환경에서 사용할 수 있을 만큼 충분히 빠르다는 것입니다. 우리는 단일 패스 바이트코드 컴파일러, 자체 내부 명령어 집합을 위한 견고한 가상 머신 인터프리터, 압축된 객체 표현, 힙 할당 없이 변수를 저장하는 스택, 그리고 정확한 가비지 컬렉터를 갖추고 있습니다.

이제 Lua, Python, 또는 Ruby의 구현을 살펴보면, 얼마나 많은 부분이 익숙하게 느껴지는지 놀라게 될 것입니다. 여러분은 프로그래밍 언어가 어떻게 작동하는지에 대한 지식을 진지하게 한 단계 끌어올렸고, 이는 다시 프로그래밍 자체에 대한 더 깊은 이해를 제공할 것입니다. 마치 이전에는 레이싱 카 드라이버였지만, 이제는 후드를 열고 엔진까지 수리할 수 있게 된 것과 같습니다.

원한다면 여기서 멈출 수 있습니다. 여러분이 가진 Lox의 두 가지 구현은 완전하고 모든 기능을 갖추고 있습니다. 이제 여러분은 자동차를 만들었고 원하는 곳 어디든 운전할 수 있습니다. 하지만 트랙에서 훨씬 더 뛰어난 성능을 위해 튜닝하고 조정하는 재미를 더 느끼고 싶다면, 한 장이 더 남아 있습니다. 우리는 새로운 기능을 추가하지는 않지만, 성능을 더욱 끌어올리기 위한 몇 가지 고전적인 최적화를 도입합니다. 이것이 재미있게 들린다면, 계속 읽어보세요 . . . 

도전 과제

  1. 객체 지향 프로그래밍의 한 가지 원칙은 클래스가 새로운 객체가 유효한 상태에 있도록 보장해야 한다는 것입니다. Lox에서는 인스턴스의 필드를 채우는 초기화자를 정의하는 것을 의미합니다. 상속은 객체의 상속 체인에 있는 모든 클래스에 따라 인스턴스가 유효한 상태여야 하므로 불변식(invariants)을 복잡하게 만듭니다.

    쉬운 부분은 각 하위 클래스의 init() 메서드에서 super.init()을 호출하는 것을 기억하는 것입니다. 어려운 부분은 필드입니다. 상속 체인에 있는 두 클래스가 실수로 동일한 필드 이름을 주장하는 것을 막는 것이 없습니다. 이런 일이 발생하면, 서로의 필드를 밟아 덮어쓰게 되어 인스턴스를 손상된 상태로 남길 수 있습니다.

    만약 Lox가 당신의 언어였다면, 이 문제를 어떻게 다루겠습니까? 아니면 아예 다루지 않겠습니까? 언어를 변경한다면, 그 변경 사항을 구현해 보세요.

  2. 우리의 하향 복사 상속(copy-down inheritance) 최적화는 Lox가 클래스 선언 후에 메서드를 수정하는 것을 허용하지 않기 때문에 유효합니다. 이는 상위 클래스에 대한 나중의 변경 사항과 하위 클래스에 복사된 메서드가 동기화되지 않을까 걱정할 필요가 없다는 것을 의미합니다.

    Ruby와 같은 다른 언어들은 클래스가 선언된 이후에도 수정되는 것을 허용합니다. 그런 언어의 구현은 메서드 해결(method resolution)을 효율적으로 유지하면서 클래스 수정을 어떻게 지원할까요?

  3. 상속에 대한 jlox 장에서, 우리는 BETA 언어의 메서드 오버라이딩 접근 방식을 구현하는 도전 과제를 가졌습니다. 이번에는 clox에서 이 도전 과제를 다시 해결해 보세요. 다음은 이전 도전 과제에 대한 설명입니다:

    Lox에서는 대부분의 다른 객체 지향 언어와 마찬가지로, 메서드를 찾을 때 클래스 계층 구조의 바닥부터 시작하여 위로 올라갑니다—하위 클래스의 메서드가 상위 클래스의 메서드보다 우선합니다. 오버라이딩하는 메서드 내에서 상위 클래스 메서드에 접근하려면 super를 사용합니다.

    언어 BETA반대 접근 방식을 취합니다. 메서드를 호출할 때 클래스 계층 구조의 최상단부터 시작하여 아래로 내려갑니다. 상위 클래스 메서드가 하위 클래스 메서드보다 우선합니다. 하위 클래스 메서드에 접근하기 위해, 상위 클래스 메서드는 inner를 호출할 수 있으며, 이는 super의 역순과 비슷합니다. 이는 계층 구조에서 다음 메서드로 체인을 연결합니다.

    상위 클래스 메서드는 하위 클래스가 자신의 동작을 정제할 수 있는 시점과 위치를 제어합니다. 만약 상위 클래스 메서드가 inner를 전혀 호출하지 않는다면, 하위 클래스는 상위 클래스의 동작을 오버라이드하거나 수정할 방법이 없습니다.

    Lox의 현재 오버라이딩 및 super 동작을 제거하고, BETA의 의미론으로 대체하세요. 요약하자면:

    • 클래스에서 메서드를 호출할 때, 클래스의 상속 체인에서 가장 높은 메서드가 우선순위를 가집니다.

    • 메서드 본문 내에서, inner 호출은 inner를 포함하는 클래스와 this 클래스 사이의 상속 체인을 따라 가장 가까운 하위 클래스에서 동일한 이름의 메서드를 찾습니다. 일치하는 메서드가 없으면 inner 호출은 아무것도 하지 않습니다.

    예시:

    class Doughnut {
      cook() {
        print "Fry until golden brown.";
        inner();
        print "Place in a nice box.";
      }
    }
    
    class BostonCream < Doughnut {
      cook() {
        print "Pipe full of custard and coat with chocolate.";
      }
    }
    
    BostonCream().cook();
    

    이것은 다음을 출력해야 합니다:

    Fry until golden brown.
    Pipe full of custard and coat with chocolate.
    Place in a nice box.
    

    clox는 단순히 Lox를 구현하는 것을 넘어 좋은 성능으로 구현하는 것이 목표이므로, 이번에는 효율성을 고려하여 도전 과제를 해결해 보세요.