13

상속

우리는 한때 바다의 작은 물방울이었고, 물고기였고, 도마뱀과 쥐였다가 원숭이가 되었고, 그 사이 수백 가지의 존재를 거쳐왔다. 이 손은 한때 지느러미였고, 한때 발톱을 가졌다! 인간의 입 안에는 늑대의 뾰족한 이빨과 토끼의 끌 모양 이빨, 소의 맷돌 이빨이 있다! 우리의 피는 우리가 살았던 바다처럼 짜다! 두려움을 느낄 때, 우리 피부의 털은 마치 털이 있었을 때처럼 곤두선다. 우리는 역사다! 우리가 되기까지 거쳐온 모든 것은, 여전히 우리 안에 존재한다.

테리 프래쳇, 어 햇 풀 오브 스카이 (A Hat Full of Sky)

믿기지 않으시겠지만, 우리는 2부의 마지막 장에 도달했습니다. 첫 번째 Lox 인터프리터를 거의 완성했습니다. 이전 장은 객체 지향 기능들이 복잡하게 얽혀 있는 거대한 덩어리였습니다. 그 기능들을 서로 분리할 수는 없었지만, 한 조각만은 풀어낼 수 있었습니다. 이번 장에서는 상속을 추가하여 Lox의 클래스 지원을 마무리할 것입니다.

상속은 최초의 객체 지향 언어인 Simula로 거슬러 올라가는 객체 지향 언어의 중요한 기능입니다. 초창기에 Kristen Nygaard와 Ole-Johan Dahl은 자신들이 작성한 시뮬레이션 프로그램에서 클래스들 간의 공통점을 발견했습니다. 상속은 이러한 유사한 부분의 코드를 재사용할 수 있는 방법을 제공했습니다.

13 . 1 슈퍼클래스와 서브클래스

개념이 "상속"이니 "부모" 클래스와 "자식" 클래스처럼 일관된 비유를 택했으면 좋았겠지만, 너무 쉬웠겠죠. 오래 전 C. A. R. Hoare는 다른 타입을 정제하는 레코드 타입을 지칭하기 위해 "서브클래스"라는 용어를 만들었습니다. Simula는 이 용어를 다른 클래스로부터 상속받는 클래스를 지칭하기 위해 빌려왔습니다. Smalltalk가 등장하기 전까지는 라틴어 접두사를 뒤집어 관계의 다른 쪽을 지칭하는 "슈퍼클래스"라는 용어를 사용한 사람이 없었던 것 같습니다. C++에서는 "기본(base)" 클래스와 "파생(derived)" 클래스라는 용어도 들을 수 있습니다. 저는 주로 "슈퍼클래스"와 "서브클래스"라는 용어를 사용할 것입니다.

Lox에서 상속을 지원하기 위한 첫 번째 단계는 클래스를 선언할 때 슈퍼클래스를 지정하는 방법입니다. 이에 대한 구문은 매우 다양합니다. C++과 C#은 서브클래스 이름 뒤에 :를 붙이고 슈퍼클래스 이름을 따릅니다. Java는 콜론 대신 extends를 사용합니다. Python은 슈퍼클래스 이름을 클래스 이름 뒤의 괄호 안에 넣습니다. Simula는 class 키워드 앞에 슈퍼클래스 이름을 둡니다.

이 시점에서 새로운 예약어나 토큰을 렉서에 추가하고 싶지는 않습니다. extends:도 없으므로, Ruby처럼 작다(<) 기호를 사용할 것입니다.

class Doughnut {
  // 일반 도넛 관련 기능...
}

class BostonCream < Doughnut {
  // 보스턴 크림 전용 기능...
}

이를 문법에 통합하기 위해, 기존 classDecl 규칙에 새로운 선택적 절을 추가합니다.

classDecl"class" IDENTIFIER ( "<" IDENTIFIER )?
                 "{" function* "}" ;

클래스 이름 뒤에 <와 슈퍼클래스 이름이 올 수 있습니다. 슈퍼클래스 절은 선택 사항입니다. 왜냐하면 슈퍼클래스가 필수는 아니기 때문입니다. Java와 같은 일부 다른 객체 지향 언어와 달리, Lox는 모든 것이 상속받는 루트 "Object" 클래스가 없으므로, 슈퍼클래스 절을 생략하면 클래스에는 암시적인 슈퍼클래스조차 없습니다.

이 새로운 구문을 클래스 선언의 AST 노드에 담으려 합니다.

      "Block      : List<Stmt> statements",
tool/GenerateAst.java
in main()
replace 1 line
      "Class      : Token name, Expr.Variable superclass," +
                  " List<Stmt.Function> methods",
      "Expression : Expr expression",
tool/GenerateAst.java, in main(), replace 1 line

슈퍼클래스 이름을 Token이 아닌 Expr.Variable로 저장한다는 사실에 놀랄 수도 있습니다. 문법은 슈퍼클래스 절을 단일 식별자로 제한하지만, 런타임에는 이 식별자가 변수 접근으로 평가됩니다. 파서 초기에 이름을 Expr.Variable로 래핑하면 리졸버가 해석 정보를 연결할 수 있는 객체를 얻을 수 있습니다.

새로운 파서 코드는 문법을 직접 따릅니다.

    Token name = consume(IDENTIFIER, "Expect class name.");
lox/Parser.java
in classDeclaration()

    Expr.Variable superclass = null;
    if (match(LESS)) {
      consume(IDENTIFIER, "Expect superclass name.");
      superclass = new Expr.Variable(previous());
    }

    consume(LEFT_BRACE, "Expect '{' before class body.");
lox/Parser.java, in classDeclaration()

(아마도) 슈퍼클래스 선언을 파싱한 후, 이를 AST에 저장합니다.

    consume(RIGHT_BRACE, "Expect '}' after class body.");

lox/Parser.java
in classDeclaration()
replace 1 line
    return new Stmt.Class(name, superclass, methods);
  }
lox/Parser.java, in classDeclaration(), replace 1 line

슈퍼클래스 절을 파싱하지 않았다면, 슈퍼클래스 표현식은 null이 됩니다. 이후 단계에서는 이를 확인해야 합니다. 그중 첫 번째는 리졸버입니다.

    define(stmt.name);
lox/Resolver.java
in visitClassStmt()

    if (stmt.superclass != null) {
      resolve(stmt.superclass);
    }

    beginScope();
lox/Resolver.java, in visitClassStmt()

클래스 선언 AST 노드에 새로운 하위 표현식이 있으므로, 우리는 그것을 순회하고 해석합니다. 클래스는 일반적으로 최상위 레벨에서 선언되므로, 슈퍼클래스 이름은 대부분 전역 변수일 것이며, 이 경우 이 작업은 특별히 유용한 것을 하지 않습니다. 하지만 Lox는 블록 내부에서도 클래스 선언을 허용하므로, 슈퍼클래스 이름이 지역 변수를 참조할 가능성도 있습니다. 이 경우, 해당 변수가 해석되었는지 확인해야 합니다.

선량한 프로그래머라도 가끔 이상한 코드를 작성하기 때문에, 우리가 여기에 있는 동안 걱정해야 할 사소한 엣지 케이스가 있습니다. 다음 코드를 보세요:

class Oops < Oops {}

이것이 유용하게 작동할 방법은 없으며, 런타임이 이를 실행하려고 시도하면, 인터프리터가 상속 체인에 순환이 없을 것이라는 기대가 깨질 것입니다. 가장 안전한 방법은 이 경우를 정적으로 감지하여 오류로 보고하는 것입니다.

    define(stmt.name);

lox/Resolver.java
in visitClassStmt()
    if (stmt.superclass != null &&
        stmt.name.lexeme.equals(stmt.superclass.name.lexeme)) {
      Lox.error(stmt.superclass.name,
          "A class can't inherit from itself.");
    }

    if (stmt.superclass != null) {
lox/Resolver.java, in visitClassStmt()

코드가 오류 없이 해석되었다고 가정하면, AST는 인터프리터로 전달됩니다.

  public Void visitClassStmt(Stmt.Class stmt) {
lox/Interpreter.java
in visitClassStmt()
    Object superclass = null;
    if (stmt.superclass != null) {
      superclass = evaluate(stmt.superclass);
      if (!(superclass instanceof LoxClass)) {
        throw new RuntimeError(stmt.superclass.name,
            "Superclass must be a class.");
      }
    }

    environment.define(stmt.name.lexeme, null);
lox/Interpreter.java, in visitClassStmt()

클래스에 슈퍼클래스 표현식이 있다면, 우리는 그것을 평가합니다. 이는 잠재적으로 다른 종류의 객체로 평가될 수 있으므로, 런타임에 슈퍼클래스가 되려는 것이 실제로 클래스인지 확인해야 합니다. 다음과 같은 코드를 허용하면 문제가 발생할 수 있습니다.

var NotAClass = "나는 전혀 클래스가 아니다";

class Subclass < NotAClass {} // ?!

해당 검사를 통과했다고 가정하고 계속 진행합니다. 클래스 선언을 실행하면 클래스의 문법적 표현(AST 노드)이 런타임 표현인 LoxClass 객체로 변환됩니다. 슈퍼클래스도 그 안으로 연결해야 합니다. 우리는 슈퍼클래스를 생성자에 전달합니다.

      methods.put(method.name.lexeme, function);
    }

lox/Interpreter.java
in visitClassStmt()
replace 1 line
    LoxClass klass = new LoxClass(stmt.name.lexeme,
        (LoxClass)superclass, methods);

    environment.assign(stmt.name, klass);
lox/Interpreter.java, in visitClassStmt(), replace 1 line

생성자는 필드에 이를 저장합니다.

lox/LoxClass.java
constructor LoxClass()
replace 1 line
  LoxClass(String name, LoxClass superclass,
           Map<String, LoxFunction> methods) {
    this.superclass = superclass;
    this.name = name;
lox/LoxClass.java, constructor LoxClass(), replace 1 line

여기서 선언합니다:

  final String name;
lox/LoxClass.java
in class LoxClass
  final LoxClass superclass;
  private final Map<String, LoxFunction> methods;
lox/LoxClass.java, in class LoxClass

이로써 다른 클래스의 서브클래스인 클래스를 정의할 수 있게 되었습니다. 이제 슈퍼클래스를 갖는 것이 실제로 무엇을 하는 것일까요?

13 . 2 메서드 상속

다른 클래스로부터 상속받는다는 것은 슈퍼클래스에 대해 참인 모든 것이 서브클래스에서도 거의 참이어야 한다는 것을 의미합니다. 정적 타입 언어에서는 이것이 많은 함의를 가집니다. 서브클래스는 또한 서브타입이어야 하며, 메모리 레이아웃은 서브클래스의 인스턴스를 슈퍼클래스를 기대하는 함수에 전달했을 때 상속된 필드에 올바르게 접근할 수 있도록 제어됩니다.

Lox는 동적 타입 언어이므로, 우리의 요구사항은 훨씬 더 간단합니다. 기본적으로, 슈퍼클래스의 인스턴스에서 어떤 메서드를 호출할 수 있다면, 서브클래스의 인스턴스가 주어졌을 때도 그 메서드를 호출할 수 있어야 한다는 의미입니다. 다시 말해, 메서드는 슈퍼클래스로부터 상속됩니다.

이것은 상속의 목표 중 하나인, 사용자가 클래스 간에 코드를 재사용할 수 있는 방법을 제공하는 것과 일치합니다. 이를 우리 인터프리터에서 구현하는 것은 놀랍도록 쉽습니다.

      return methods.get(name);
    }

lox/LoxClass.java
in findMethod()
    if (superclass != null) {
      return superclass.findMethod(name);
    }

    return null;
lox/LoxClass.java, in findMethod()

말 그대로 이것이 전부입니다. 인스턴스에서 메서드를 찾을 때, 인스턴스의 클래스에서 찾지 못하면 슈퍼클래스 체인을 통해 재귀적으로 위로 올라가 거기서 찾습니다. 시도해 보세요:

class Doughnut {
  cook() {
    print "황금빛 갈색이 될 때까지 튀기세요.";
  }
}

class BostonCream < Doughnut {}

BostonCream().cook();

이제 됐습니다. 세 줄의 Java 코드만으로 상속 기능의 절반을 완성했습니다.

13 . 3 슈퍼클래스 메서드 호출

findMethod()에서 우리는 슈퍼클래스 체인을 거슬러 올라가기 전에 현재 클래스에서 메서드를 찾습니다. 만약 서브클래스와 슈퍼클래스 모두에 같은 이름의 메서드가 존재한다면, 서브클래스의 메서드가 우선권을 가지거나 슈퍼클래스 메서드를 오버라이드합니다. 이는 내부 스코프의 변수가 외부 스코프의 변수를 가리는 것과 비슷합니다.

이는 서브클래스가 슈퍼클래스의 특정 동작을 완전히 대체하려고 할 때 유용합니다. 하지만 실제로는 서브클래스가 슈퍼클래스의 동작을 정제하려는 경우가 많습니다. 서브클래스에 특화된 작업을 조금 수행하면서도 원래 슈퍼클래스의 동작도 실행하고 싶은 것입니다.

그러나 서브클래스가 메서드를 오버라이드했기 때문에 원래 메서드를 참조할 방법이 없습니다. 서브클래스 메서드가 이름으로 호출을 시도하면, 자기 자신의 오버라이드된 메서드를 재귀적으로 호출하게 될 것입니다. 우리는 "이 메서드를 호출하되, 슈퍼클래스에서 직접 찾아 내 오버라이드를 무시하라"고 말할 방법이 필요합니다. Java는 이를 위해 super를 사용하며, Lox에서도 같은 구문을 사용할 것입니다. 다음은 예시입니다:

class Doughnut {
  cook() {
    print "황금빛 갈색이 될 때까지 튀기세요.";
  }
}

class BostonCream < Doughnut {
  cook() {
    super.cook();
    print "커스터드로 채우고 초콜릿을 입히세요.";
  }
}

BostonCream().cook();

이것을 실행하면 다음과 같이 출력됩니다:

황금빛 갈색이 될 때까지 튀기세요.
커스터드로 채우고 초콜릿을 입히세요.

새로운 표현식 형식이 있습니다. super 키워드 다음에 점(.)과 식별자가 오면 해당 이름의 메서드를 찾습니다. this를 통한 호출과는 달리, 검색은 슈퍼클래스에서 시작됩니다.

13 . 3 . 1 구문

this의 경우, 키워드는 일종의 마법 변수처럼 작동하고, 표현식은 그 하나의 토큰입니다. 하지만 super의 경우, 뒤따르는 .과 속성 이름은 super 표현식의 분리할 수 없는 부분입니다. 단독으로 super 토큰만 사용할 수는 없습니다.

print super; // 문법 오류.

따라서 우리 문법의 primary 규칙에 추가하는 새 절에는 속성 접근도 포함됩니다.

primary"true" | "false" | "nil" | "this"
               | NUMBER | STRING | IDENTIFIER | "(" expression ")"
               | "super" "." IDENTIFIER ;

일반적으로 super 표현식은 메서드 호출에 사용되지만, 일반 메서드와 마찬가지로 인자 목록은 표현식의 일부가 아닙니다. 대신, 슈퍼 호출은 슈퍼 접근 다음에 함수 호출이 이어지는 형태입니다. 다른 메서드 호출과 마찬가지로, 슈퍼클래스 메서드에 대한 핸들을 얻어 별도로 호출할 수 있습니다.

var method = super.cook;
method();

따라서 super 표현식 자체는 super 키워드에 대한 토큰과 찾아볼 메서드 이름만 포함합니다. 해당하는 구문 트리 노드는 다음과 같습니다:

      "Set      : Expr object, Token name, Expr value",
tool/GenerateAst.java
in main()
      "Super    : Token keyword, Token method",
      "This     : Token keyword",
tool/GenerateAst.java, in main()

문법에 따라 새로운 파싱 코드는 기존 primary() 메서드 안에 들어갑니다.

      return new Expr.Literal(previous().literal);
    }
lox/Parser.java
in primary()

    if (match(SUPER)) {
      Token keyword = previous();
      consume(DOT, "Expect '.' after 'super'.");
      Token method = consume(IDENTIFIER,
          "Expect superclass method name.");
      return new Expr.Super(keyword, method);
    }

    if (match(THIS)) return new Expr.This(previous());
lox/Parser.java, in primary()

선행하는 super 키워드는 super 표현식을 만났음을 알려줍니다. 그 다음에는 예상되는 .과 메서드 이름을 소비(consume)합니다.

13 . 3 . 2 의미

앞서 super 표현식이 "슈퍼클래스"에서 메서드 조회를 시작한다고 말했지만, 어떤 슈퍼클래스일까요? 순진한 답변은 현재 메서드가 호출된 객체인 this의 슈퍼클래스입니다. 이것은 많은 경우에 우연히 올바른 동작을 만들어내지만, 실제로는 정확하지 않습니다. 다음 코드를 살펴보세요:

class A {
  method() {
    print "A 메서드";
  }
}

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

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

class C < B {}

C().test();

이 프로그램을 Java, C#, 또는 C++로 번역하면 "A method"를 출력할 것이며, 이것이 우리가 Lox가 하길 원하는 것입니다. 이 프로그램이 test()의 본문 내부에서 실행될 때, this는 C의 인스턴스입니다. C의 슈퍼클래스는 B이지만, 이것이 조회(lookup)가 시작되어야 하는 곳이 아닙니다. 만약 B에서 시작한다면, 우리는 B의 method()를 호출하게 될 것입니다.

대신, 조회는 super 표현식을 포함하는 클래스의 슈퍼클래스에서 시작되어야 합니다. 이 경우, test()가 B 내부에 정의되어 있으므로, 그 안에 있는 super 표현식은 B의 슈퍼클래스, 즉 A에서 조회를 시작해야 합니다.

The call chain flowing through the classes.

따라서 super 표현식을 평가하려면, 호출을 둘러싼 클래스 정의의 슈퍼클래스에 접근해야 합니다. 안타깝게도 인터프리터에서 super 표현식을 실행하는 시점에는 그것을 쉽게 사용할 수 없습니다.

LoxFunction에 해당 메서드를 소유하는 LoxClass에 대한 참조를 저장하는 필드를 추가할 수도 있습니다. 인터프리터는 현재 실행 중인 LoxFunction에 대한 참조를 유지하여, super 표현식을 만났을 때 나중에 찾아볼 수 있도록 할 것입니다. 거기에서 해당 메서드의 LoxClass를 얻고, 그 다음 그 슈퍼클래스를 얻을 것입니다.

그것은 많은 배관(plumbing) 작업입니다. 지난 장에서 this 지원을 추가해야 했을 때도 비슷한 문제가 있었습니다. 그 경우, 우리는 기존의 환경(environment)과 클로저(closure) 메커니즘을 사용하여 현재 객체에 대한 참조를 저장했습니다. 슈퍼클래스를 저장하는 데에도 비슷한 방법을 사용할 수 있을까요? 글쎄요, 답이 아니었다면 제가 이런 이야기를 하지 않았겠죠. 그러니... 네, 그렇습니다.

한 가지 중요한 차이점은 우리가 메서드가 접근되었을this를 바인딩했다는 것입니다. 같은 메서드가 다른 인스턴스에서 호출될 수 있고, 각 인스턴스는 자신만의 this를 필요로 합니다. super 표현식의 경우, 슈퍼클래스는 클래스 선언 자체의 고정된 속성입니다. super 표현식을 평가할 때마다 슈퍼클래스는 항상 동일합니다.

이는 클래스 정의가 실행될 때 슈퍼클래스에 대한 환경을 한 번만 생성할 수 있음을 의미합니다. 메서드를 정의하기 직전에, 클래스의 슈퍼클래스를 super라는 이름에 바인딩할 새로운 환경을 만듭니다.

The superclass environment.

각 메서드에 대한 LoxFunction 런타임 표현을 생성할 때, 그것이 클로저에 캡처할 환경이 될 것입니다. 나중에 메서드가 호출되고 this가 바인딩되면, 슈퍼클래스 환경이 메서드 환경의 부모가 됩니다. 다음과 같습니다:

The environment chain including the superclass environment.

많은 장치들이 필요하지만, 차근차근 해결해 나갈 것입니다. 런타임에 환경을 생성하기 전에, 리졸버에서 해당하는 스코프 체인을 처리해야 합니다.

      resolve(stmt.superclass);
    }
lox/Resolver.java
in visitClassStmt()

    if (stmt.superclass != null) {
      beginScope();
      scopes.peek().put("super", true);
    }

    beginScope();
lox/Resolver.java, in visitClassStmt()

클래스 선언에 슈퍼클래스가 있다면, 모든 메서드를 둘러싸는 새로운 스코프를 생성합니다. 그 스코프 안에서 "super"라는 이름을 정의합니다. 클래스의 메서드 해석이 끝나면 해당 스코프는 폐기됩니다.

    endScope();

lox/Resolver.java
in visitClassStmt()
    if (stmt.superclass != null) endScope();

    currentClass = enclosingClass;
lox/Resolver.java, in visitClassStmt()

사소한 최적화이지만, 클래스에 실제로 슈퍼클래스가 있는 경우에만 슈퍼클래스 환경을 생성합니다. 슈퍼클래스가 없을 때는 어차피 슈퍼클래스를 저장할 필요가 없으므로 생성할 이유가 없습니다.

"super"가 스코프 체인에 정의되었으므로, super 표현식 자체를 해석할 수 있습니다.

lox/Resolver.java
add after visitSetExpr()
  @Override
  public Void visitSuperExpr(Expr.Super expr) {
    resolveLocal(expr, expr.keyword);
    return null;
  }
lox/Resolver.java, add after visitSetExpr()

우리는 super 토큰을 변수인 것처럼 정확히 해석합니다. 해석은 인터프리터가 슈퍼클래스가 저장된 환경을 찾기 위해 걸어가야 할 환경 체인의 홉(hops) 수를 저장합니다.

이 코드는 인터프리터에 반영됩니다. 서브클래스 정의를 평가할 때, 우리는 새로운 환경을 생성합니다.

        throw new RuntimeError(stmt.superclass.name,
            "Superclass must be a class.");
      }
    }

    environment.define(stmt.name.lexeme, null);
lox/Interpreter.java
in visitClassStmt()

    if (stmt.superclass != null) {
      environment = new Environment(environment);
      environment.define("super", superclass);
    }

    Map<String, LoxFunction> methods = new HashMap<>();
lox/Interpreter.java, in visitClassStmt()

이 환경 안에, 우리는 슈퍼클래스에 대한 참조를 저장합니다 — 이제 런타임에 있으므로 실제 LoxClass 슈퍼클래스 객체를 말입니다. 그런 다음 각 메서드에 대한 LoxFunction을 생성합니다. 이 LoxFunction들은 현재 환경(우리가 방금 "super"를 바인딩한 환경)을 클로저로 캡처하여 필요한 대로 슈퍼클래스를 유지할 것입니다. 이 작업이 완료되면, 환경을 팝(pop)합니다.

    LoxClass klass = new LoxClass(stmt.name.lexeme,
        (LoxClass)superclass, methods);
lox/Interpreter.java
in visitClassStmt()

    if (superclass != null) {
      environment = environment.enclosing;
    }

    environment.assign(stmt.name, klass);
lox/Interpreter.java, in visitClassStmt()

이제 super 표현식 자체를 해석할 준비가 되었습니다. 여러 가지 움직이는 부분이 있으므로, 이 메서드를 조금씩 만들어 나가겠습니다.

lox/Interpreter.java
add after visitSetExpr()
  @Override
  public Object visitSuperExpr(Expr.Super expr) {
    int distance = locals.get(expr);
    LoxClass superclass = (LoxClass)environment.getAt(
        distance, "super");
  }
lox/Interpreter.java, add after visitSetExpr()

먼저, 우리가 목표로 했던 작업입니다. 적절한 환경에서 "super"를 찾아 주변 클래스의 슈퍼클래스를 찾습니다.

메서드에 접근할 때, this를 메서드가 접근된 객체에 바인딩할 필요도 있습니다. doughnut.cook과 같은 표현식에서 객체는 doughnut을 평가하여 얻는 모든 것입니다. super.cook과 같은 super 표현식에서는 현재 객체가 암시적으로 우리가 사용하고 있는 동일한 현재 객체, 즉 this입니다. 다시 말해, 우리는 슈퍼클래스에서 메서드를 찾고 있지만, 인스턴스는 여전히 this입니다.

불행히도 super 표현식 내부에는 리졸버가 this까지의 홉 수를 연결할 수 있는 편리한 노드가 없습니다. 다행히도 우리는 환경 체인의 레이아웃을 제어합니다. "this"가 바인딩되는 환경은 항상 "super"가 저장되는 환경 바로 안쪽에 있습니다.

    LoxClass superclass = (LoxClass)environment.getAt(
        distance, "super");
lox/Interpreter.java
in visitSuperExpr()

    LoxInstance object = (LoxInstance)environment.getAt(
        distance - 1, "this");
  }
lox/Interpreter.java, in visitSuperExpr()

거리를 1만큼 오프셋하면 내부 환경에서 "this"를 찾습니다. 가장 우아한 코드는 아니지만 작동합니다.

이제 슈퍼클래스에서 시작하여 메서드를 찾고 바인딩할 준비가 되었습니다.

    LoxInstance object = (LoxInstance)environment.getAt(
        distance - 1, "this");
lox/Interpreter.java
in visitSuperExpr()

    LoxFunction method = superclass.findMethod(expr.method.lexeme);
    return method.bind(object);
  }
lox/Interpreter.java, in visitSuperExpr()

이것은 get 표현식의 메서드를 찾는 코드와 거의 정확히 같습니다. 단, 현재 객체의 클래스 대신 슈퍼클래스에서 findMethod()를 호출한다는 점만 다릅니다.

기본적으로는 그렇습니다. 물론 메서드를 찾지 못할 수도 있다는 점만 제외하고 말이죠. 그래서 그 경우도 확인합니다.


    LoxFunction method = superclass.findMethod(expr.method.lexeme);
lox/Interpreter.java
in visitSuperExpr()

    if (method == null) {
      throw new RuntimeError(expr.method,
          "Undefined property '" + expr.method.lexeme + "'.");
    }

    return method.bind(object);
  }
lox/Interpreter.java, in visitSuperExpr()

자, 이제 됐습니다! 이전에 보스턴 크림 예제를 가져와서 시도해 보세요. 당신과 제가 모든 것을 올바르게 했다면, 먼저 튀겨진 다음 크림으로 채워질 것입니다.

13 . 3 . 3 잘못된 super 사용

이전 언어 기능들과 마찬가지로, 우리의 구현은 사용자가 올바른 코드를 작성할 때는 제대로 작동하지만, 잘못된 코드로부터 인터프리터를 완벽하게 보호하지는 못했습니다. 특히 다음을 고려해 보세요:

class Eclair {
  cook() {
    super.cook();
    print "크렘 파티시에로 가득 채우세요.";
  }
}

이 클래스에는 super 표현식이 있지만 슈퍼클래스가 없습니다. 런타임에 super 표현식을 평가하는 코드는 "super"가 성공적으로 해석되어 환경에서 찾아질 것이라고 가정합니다. 여기서는 슈퍼클래스가 없으므로 슈퍼클래스에 대한 주변 환경이 없어 실패할 것입니다. JVM은 예외를 던지고 우리 인터프리터를 다운시킬 것입니다.

젠장, super의 더 간단하고 망가진 사용법도 있습니다:

super.notEvenInAClass();

"super" 검색이 성공했는지 확인하여 런타임에 이러한 오류를 처리할 수 있습니다. 그러나 우리는 정적으로(단지 소스 코드를 보는 것만으로도) Eclair가 슈퍼클래스를 가지고 있지 않으므로 그 안에 어떤 super 표현식도 작동하지 않을 것임을 알 수 있습니다. 마찬가지로, 두 번째 예제에서는 super 표현식이 메서드 본문 안에 있지도 않다는 것을 알 수 있습니다.

Lox가 동적 타입 언어라고 해서 모든 것을 런타임으로 미루고 싶지는 않습니다. 사용자가 실수를 했다면, 가능한 한 빨리 그 실수를 찾도록 돕고 싶습니다. 따라서 우리는 이러한 오류를 리졸버에서 정적으로 보고할 것입니다.

    NONE,
    CLASS,
lox/Resolver.java
in enum ClassType
add “,” to previous line
    SUBCLASS
  }
lox/Resolver.java, in enum ClassType, add “,” to previous line

우리는 이것을 사용하여 슈퍼클래스를 가진 클래스 내부에 있는지 여부를 구별할 것입니다. 클래스 선언을 해석할 때, 클래스가 서브클래스인 경우 이 값을 설정합니다.

    if (stmt.superclass != null) {
lox/Resolver.java
in visitClassStmt()
      currentClass = ClassType.SUBCLASS;
      resolve(stmt.superclass);
lox/Resolver.java, in visitClassStmt()

그런 다음, super 표현식을 해석할 때, 현재 허용된 스코프 내부에 있는지 확인합니다.

  public Void visitSuperExpr(Expr.Super expr) {
lox/Resolver.java
in visitSuperExpr()
    if (currentClass == ClassType.NONE) {
      Lox.error(expr.keyword,
          "클래스 외부에서는 'super'를 사용할 수 없습니다.");
    } else if (currentClass != ClassType.SUBCLASS) {
      Lox.error(expr.keyword,
          "슈퍼클래스가 없는 클래스에서는 'super'를 사용할 수 없습니다.");
    }

    resolveLocal(expr, expr.keyword);
lox/Resolver.java, in visitSuperExpr()

그렇지 않다면, 사용자가 실수를 한 것입니다!

13 . 4 결론

우리는 해냈습니다! 마지막 오류 처리 코드가 Lox의 Java 구현을 완성하는 데 필요한 마지막 코드 조각입니다. 이것은 진정한 성취이며, 자랑스러워해야 할 일입니다. 지난 십여 개의 장과 천 줄 남짓한 코드를 통해 우리는 다음을 배우고 구현했습니다:

우리는 외부 의존성이나 마법 도구 없이 이 모든 것을 처음부터 만들었습니다. 당신과 저, 각자의 텍스트 편집기, Java 표준 라이브러리의 몇몇 컬렉션 클래스, 그리고 JVM 런타임만으로 말입니다.

이것으로 2부는 끝나지만, 책은 끝나지 않았습니다. 잠시 쉬세요. 재미있는 Lox 프로그램을 몇 개 작성하고 인터프리터에서 실행해 보세요. (사용자 입력 읽기 등 몇 가지 네이티브 메서드를 더 추가하고 싶을 수도 있습니다.) 재충전하고 준비가 되면, 다음 모험을 시작할 것입니다.

도전 과제

  1. Lox는 단일 상속만 지원합니다 — 클래스는 하나의 슈퍼클래스만 가질 수 있으며, 이것이 클래스 간에 메서드를 재사용하는 유일한 방법입니다. 다른 언어들은 클래스 간에 기능을 더 자유롭게 재사용하고 공유하기 위한 다양한 방법을 탐구했습니다: 믹스인(mixins), 트레잇(traits), 다중 상속(multiple inheritance), 가상 상속(virtual inheritance), 확장 메서드(extension methods) 등.

    이러한 종류의 기능을 Lox에 추가한다면, 어떤 것을 선택하고 왜 선택할 것인가요? 용기가 있다면 (이 시점에서는 용감해야 합니다), 지금 바로 추가해 보세요.

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

    BETA 언어는 반대 접근 방식을 취합니다. 메서드를 호출할 때, 클래스 계층 구조의 맨 위에서 시작하여 아래로 내려갑니다. 슈퍼클래스 메서드가 서브클래스 메서드보다 우선합니다. 서브클래스 메서드에 접근하기 위해, 슈퍼클래스 메서드는 super의 역순과 같은 inner를 호출할 수 있습니다. 이는 계층 구조에서 다음 하위 메서드로 연결됩니다.

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

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

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

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

    예를 들어:

    class Doughnut {
      cook() {
        print "황금빛 갈색이 될 때까지 튀기세요.";
        inner();
        print "예쁜 상자에 담으세요.";
      }
    }
    
    class BostonCream < Doughnut {
      cook() {
        print "커스터드로 채우고 초콜릿을 입히세요.";
      }
    }
    
    BostonCream().cook();
    

    이것은 다음과 같이 출력되어야 합니다:

    황금빛 갈색이 될 때까지 튀기세요.
    커스터드로 채우고 초콜릿을 입히세요.
    예쁜 상자에 담으세요.
    
  3. Lox를 소개했던 장에서, 저는 여러분에게 언어에 빠져 있다고 생각하는 몇 가지 기능을 제안하라는 도전을 했습니다. 이제 인터프리터를 만드는 방법을 알게 되었으니, 그 기능 중 하나를 구현해 보세요.