7

표현식 평가하기

너는 나의 창조주이지만, 나는 너의 주인이다; 복종하라!

메리 셸리, 프랑켄슈타인

이 장의 분위기를 제대로 잡고 싶다면, 이야기의 절정에서 셔터를 쾅 하고 열어젖히는 거친 폭풍우를 상상해 보세요. 번개 몇 줄기도 추가하면 좋겠죠. 이 장에서는 우리 인터프리터가 숨을 쉬고, 눈을 뜨고, 코드를 실행할 것입니다.

빅토리아 시대의 저택에 번개가 내리칩니다. 으스스하네요!

언어 구현체가 사용자 소스 코드의 명령을 컴퓨터가 실행하게 하는 방법은 다양합니다. 기계 코드로 컴파일하거나, 다른 고수준 언어로 번역하거나, 가상 머신이 실행할 수 있는 바이트코드 형식으로 줄일 수 있습니다. 하지만 첫 번째 인터프리터에서는 가장 간단하고 짧은 경로를 택해 구문 트리를 직접 실행할 것입니다.

현재 우리 파서는 표현식만 지원합니다. 따라서 코드를 '실행'한다는 것은 표현식을 평가하여 값을 생성하는 것을 의미합니다. 우리가 파싱할 수 있는 각 표현식 구문 종류(리터럴, 연산자 등)마다 해당 트리를 평가하고 결과를 생성하는 방법을 아는 코드 조각이 필요합니다. 여기서 두 가지 질문이 생깁니다.

  1. 우리는 어떤 종류의 값을 생성할까요?

  2. 이러한 코드 조각들을 어떻게 구성할까요?

하나씩 살펴보겠습니다 . . . 

7 . 1값 표현하기

Lox에서 은 리터럴에 의해 생성되고, 표현식에 의해 계산되며, 변수에 저장됩니다. 사용자는 이러한 값들을 Lox 객체로 보지만, 실제로는 우리 인터프리터가 작성된 기반 언어로 구현됩니다. 이는 Lox의 동적 타이핑과 Java의 정적 타이핑 간의 격차를 해소해야 함을 의미합니다. Lox의 변수는 어떤 (Lox) 타입의 값이라도 저장할 수 있으며, 심지어 다른 시점에 다른 타입의 값을 저장할 수도 있습니다. 이를 표현하기 위해 어떤 Java 타입을 사용할 수 있을까요?

해당 정적 타입을 가진 Java 변수가 주어졌을 때, 런타임에 어떤 종류의 값을 담고 있는지 판별할 수 있어야 합니다. 인터프리터가 + 연산자를 실행할 때, 두 숫자를 더하는 것인지 두 문자열을 연결하는 것인지 파악해야 합니다. 숫자, 문자열, 불리언 등을 담을 수 있는 Java 타입이 있을까요? 런타임 타입을 알려줄 수 있는 타입이 있을까요? 있습니다! 바로 유서 깊은 java.lang.Object 입니다.

인터프리터에서 Lox 값을 저장해야 하는 곳에는 Object 타입을 사용할 수 있습니다. Java에는 기본 타입의 박싱된 버전이 있는데, 이들은 모두 Object를 서브클래스화하므로 Lox의 내장 타입을 위해 이들을 사용할 수 있습니다.

Lox 타입 Java 표현
모든 Lox 값 Object
nil null
Boolean Boolean
number Double
string String

정적 타입이 Object인 값이 주어졌을 때, Java의 내장 instanceof 연산자를 사용하여 런타임 값이 숫자인지, 문자열인지 등을 판단할 수 있습니다. 다시 말해, JVM 자체의 객체 표현 방식은 Lox의 내장 타입을 구현하는 데 필요한 모든 것을 편리하게 제공합니다. 나중에 Lox의 함수, 클래스, 인스턴스 개념을 추가할 때는 좀 더 작업이 필요하겠지만, 지금 당장 필요한 타입들에는 Object와 박싱된 기본 타입 클래스만으로 충분합니다.

7 . 2표현식 평가하기

다음으로, 파싱할 수 있는 각 표현식 종류에 대한 평가 로직을 구현할 코드 덩어리가 필요합니다. 이 코드를 interpret() 메서드와 같은 형태로 구문 트리 클래스에 넣을 수도 있습니다. 사실상 각 구문 트리 노드에게 "스스로를 해석하라"고 지시하는 방식입니다. 이는 Gang of Four의 인터프리터 디자인 패턴입니다. 깔끔한 패턴이지만, 앞서 언급했듯이 모든 종류의 로직을 트리 클래스에 밀어 넣으면 복잡해집니다.

대신, 우리는 멋진 방문자(Visitor) 패턴을 재활용할 것입니다. 이전 장에서 우리는 AstPrinter 클래스를 만들었습니다. 이 클래스는 구문 트리를 받아 재귀적으로 순회하며 문자열을 구축하여 최종적으로 반환했습니다. 실제 인터프리터가 하는 일과 거의 동일합니다. 단, 문자열을 연결하는 대신 값을 계산한다는 점만 다릅니다.

새로운 클래스로 시작합니다.

lox/Interpreter.java
새 파일 생성
package com.craftinginterpreters.lox;

class Interpreter implements Expr.Visitor<Object> {
}
lox/Interpreter.java, 새 파일 생성

이 클래스는 방문자(Visitor)임을 선언합니다. 방문(visit) 메서드들의 반환 타입은 Object가 될 것입니다. 이 Object는 Java 코드에서 Lox 값을 참조하는 데 사용하는 루트 클래스입니다. Visitor 인터페이스를 만족시키기 위해, 파서가 생성하는 네 가지 표현식 트리 클래스 각각에 대한 방문 메서드를 정의해야 합니다. 가장 간단한 것부터 시작하겠습니다 . . . 

7 . 2 . 1리터럴 평가하기

표현식 트리의 리프(leaf) 노드는 다른 모든 표현식이 구성되는 원자적인 구문 조각인 리터럴입니다. 리터럴은 거의 값과 같지만, 그 구별은 중요합니다. 리터럴은 값을 생성하는 구문 조각입니다. 리터럴은 항상 사용자 소스 코드 어딘가에 나타납니다. 많은 값들은 계산에 의해 생성되며 코드 자체에는 존재하지 않습니다. 이러한 것들은 리터럴이 아닙니다. 리터럴은 파서의 영역에 속합니다. 값은 인터프리터의 개념이며, 런타임의 세계에 속합니다.

따라서, 파서에서 리터럴 토큰을 리터럴 구문 트리 노드로 변환했던 것처럼, 이제 리터럴 트리 노드를 런타임 값으로 변환합니다. 이는 아주 간단합니다.

lox/Interpreter.java
Interpreter 클래스 내
  @Override
  public Object visitLiteralExpr(Expr.Literal expr) {
    return expr.value;
  }
lox/Interpreter.java, Interpreter 클래스 내

우리는 스캐닝 단계에서 이미 런타임 값을 생성하여 토큰에 넣어두었습니다. 파서는 그 값을 리터럴 트리 노드에 붙여넣었고, 따라서 리터럴을 평가하려면 단순히 그 값을 다시 꺼내면 됩니다.

7 . 2 . 2괄호 평가하기

다음으로 가장 간단하게 평가할 수 있는 노드는 묶음(grouping) 노드입니다. 이 노드는 표현식에서 명시적인 괄호를 사용한 결과로 얻게 됩니다.

lox/Interpreter.java
Interpreter 클래스 내
  @Override
  public Object visitGroupingExpr(Expr.Grouping expr) {
    return evaluate(expr.expression);
  }
lox/Interpreter.java, Interpreter 클래스 내

묶음(grouping) 노드는 괄호 안에 포함된 표현식에 대한 내부 노드를 참조합니다. 묶음 표현식 자체를 평가하기 위해, 해당 하위 표현식을 재귀적으로 평가하고 그 결과를 반환합니다.

인터프리터의 방문자 구현으로 표현식을 다시 보내는 이 헬퍼 메서드를 사용합니다.

lox/Interpreter.java
Interpreter 클래스 내
  private Object evaluate(Expr expr) {
    return expr.accept(this);
  }
lox/Interpreter.java, Interpreter 클래스 내

7 . 2 . 3단항 표현식 평가하기

묶음 표현식과 마찬가지로, 단항 표현식은 먼저 평가해야 할 단일 하위 표현식을 가집니다. 차이점은 단항 표현식 자체가 그 후에 약간의 작업을 수행한다는 점입니다.

lox/Interpreter.java
visitLiteralExpr() 다음에 추가
  @Override
  public Object visitUnaryExpr(Expr.Unary expr) {
    Object right = evaluate(expr.right);

    switch (expr.operator.type) {
      case MINUS:
        return -(double)right;
    }

    // 도달 불가능한 코드.
    return null;
  }
lox/Interpreter.java, visitLiteralExpr() 다음에 추가

먼저, 피연산자 표현식을 평가합니다. 그런 다음 단항 연산자 자체를 그 결과에 적용합니다. 연산자 토큰의 타입으로 식별되는 두 가지 단항 표현식이 있습니다.

여기에 표시된 -는 하위 표현식의 결과를 부정합니다. 하위 표현식은 숫자여야 합니다. Java에서는 이를 정적으로 알 수 없으므로, 연산을 수행하기 전에 캐스팅합니다. 이 타입 캐스팅은 -가 평가될 때 런타임에 발생합니다. 이것이 바로 언어가 동적 타이핑되는 핵심입니다.

평가가 트리를 재귀적으로 순회하는 방식을 볼 수 있을 것입니다. 피연산자 하위 표현식을 평가하기 전에는 단항 연산자 자체를 평가할 수 없습니다. 이는 우리 인터프리터가 후위 순회(post-order traversal)를 수행하고 있다는 의미입니다. 즉, 각 노드는 자신의 작업을 수행하기 전에 자식 노드를 평가합니다.

다른 단항 연산자는 논리 NOT입니다.

    switch (expr.operator.type) {
lox/Interpreter.java
visitUnaryExpr() 내
      case BANG:
        return !isTruthy(right);
      case MINUS:
lox/Interpreter.java, visitUnaryExpr() 내

구현은 간단하지만, 이 "참 같은(truthy)" 것은 무엇일까요? 서양 철학의 위대한 질문 중 하나로 잠깐 옆길로 새야 할 것 같습니다: 진리란 무엇인가?

7 . 2 . 4참 같은(truthy) 값과 거짓 같은(falsey) 값

좋아요, 보편적인 질문에 깊이 파고들지는 않겠지만, 적어도 Lox의 세계 안에서는 !와 같은 논리 연산이나 불리언이 예상되는 다른 어떤 곳에서든 truefalse 외의 다른 것을 사용할 때 무슨 일이 일어날지 결정해야 합니다.

암시적 타입 변환을 허용하지 않으므로 단순히 오류라고 말할 수도 있습니다. 그러나 대부분의 동적 타이핑 언어는 그렇게 금욕적이지 않습니다. 대신, 모든 타입의 값들을 두 개의 집합으로 나눕니다. 하나는 "참" 또는 "참과 같은(truthful)", 또는 (제가 가장 좋아하는) "참 같은(truthy)" 것으로 정의하고, 나머지는 "거짓" 또는 "거짓 같은(falsey)" 것으로 정의합니다. 이 구분은 다소 임의적이며 일부 언어에서는 이상해지기도 합니다.

Lox는 Ruby의 간단한 규칙을 따릅니다: falsenil은 거짓 같은(falsey) 값이고, 그 외의 모든 것은 참 같은(truthy) 값입니다. 우리는 이를 다음과 같이 구현합니다:

lox/Interpreter.java
visitUnaryExpr() 다음에 추가
  private boolean isTruthy(Object object) {
    if (object == null) return false;
    if (object instanceof Boolean) return (boolean)object;
    return true;
  }
lox/Interpreter.java, visitUnaryExpr() 다음에 추가

7 . 2 . 5이항 연산자 평가하기

마지막 표현식 트리 클래스인 이항 연산자로 넘어갑시다. 이항 연산자는 몇 가지가 있는데, 산술 연산자부터 시작할 것입니다.

lox/Interpreter.java
evaluate() 다음에 추가
  @Override
  public Object visitBinaryExpr(Expr.Binary expr) {
    Object left = evaluate(expr.left);
    Object right = evaluate(expr.right); 

    switch (expr.operator.type) {
      case MINUS:
        return (double)left - (double)right;
      case SLASH:
        return (double)left / (double)right;
      case STAR:
        return (double)left * (double)right;
    }

    // 도달 불가능한 코드.
    return null;
  }
lox/Interpreter.java, evaluate() 다음에 추가

여기서 무슨 일이 일어나고 있는지 짐작하실 수 있을 겁니다. 단항 부정(negation) 연산자와의 주요 차이점은 평가해야 할 피연산자가 두 개라는 점입니다.

하나의 산술 연산자는 좀 특별해서 빼놓았습니다.

    switch (expr.operator.type) {
      case MINUS:
        return (double)left - (double)right;
lox/Interpreter.java
visitBinaryExpr() 내
      case PLUS:
        if (left instanceof Double && right instanceof Double) {
          return (double)left + (double)right;
        } 

        if (left instanceof String && right instanceof String) {
          return (String)left + (String)right;
        }

        break;
      case SLASH:
lox/Interpreter.java, visitBinaryExpr() 내

+ 연산자는 두 문자열을 연결하는 데도 사용될 수 있습니다. 이를 처리하기 위해, 단순히 피연산자가 특정 타입이라고 가정하고 캐스팅하는 것이 아니라, 타입을 동적으로 확인하고 적절한 연산을 선택합니다. 이것이 바로 우리 객체 표현 방식이 instanceof를 지원해야 하는 이유입니다.

다음은 비교 연산자입니다.

    switch (expr.operator.type) {
lox/Interpreter.java
visitBinaryExpr() 내
      case GREATER:
        return (double)left > (double)right;
      case GREATER_EQUAL:
        return (double)left >= (double)right;
      case LESS:
        return (double)left < (double)right;
      case LESS_EQUAL:
        return (double)left <= (double)right;
      case MINUS:
lox/Interpreter.java, visitBinaryExpr() 내

이들은 기본적으로 산술 연산자와 동일합니다. 유일한 차이점은 산술 연산자가 피연산자와 동일한 타입(숫자 또는 문자열)의 값을 생성하는 반면, 비교 연산자는 항상 불리언을 생성한다는 것입니다.

마지막 연산자 쌍은 동등성입니다.

lox/Interpreter.java
visitBinaryExpr() 내
      case BANG_EQUAL: return !isEqual(left, right);
      case EQUAL_EQUAL: return isEqual(left, right);
lox/Interpreter.java, visitBinaryExpr() 내

숫자를 요구하는 비교 연산자와 달리, 동등성 연산자는 어떤 타입의 피연산자든, 심지어 혼합된 타입도 지원합니다. Lox에게 3이 "three"보다 작은지 물을 수는 없지만, 같은지는 물을 수 있습니다.

참 같은(truthiness) 논리와 마찬가지로, 동등성 논리도 별도의 메서드로 분리됩니다.

lox/Interpreter.java
isTruthy() 다음에 추가
  private boolean isEqual(Object a, Object b) {
    if (a == null && b == null) return true;
    if (a == null) return false;

    return a.equals(b);
  }
lox/Interpreter.java, isTruthy() 다음에 추가

이것은 Java 측면에서 Lox 객체를 어떻게 표현하는지에 대한 세부 사항이 중요한 부분 중 하나입니다. 우리는 Java의 동등성 개념과 다를 수 있는 Lox의 동등성 개념을 정확하게 구현해야 합니다.

다행히 둘은 상당히 유사합니다. Lox는 동등성 비교에서 암시적 변환을 수행하지 않으며 Java도 마찬가지입니다. nullequals()를 호출하려고 할 때 NullPointerException이 발생하지 않도록 nil/null을 특별히 처리해야 합니다. 그 외에는 괜찮습니다. Java의 Boolean, Double, String 클래스의 equals() 메서드는 Lox가 원하는 동작을 가지고 있습니다.

이것으로 끝입니다! 유효한 Lox 표현식을 올바르게 해석하는 데 필요한 모든 코드입니다. 하지만 유효하지 않은 표현식은 어떨까요? 특히, 하위 표현식이 수행되는 연산에 잘못된 타입의 객체로 평가될 때 무슨 일이 일어날까요?

7 . 3런타임 에러

저는 하위 표현식이 Object를 생성하고 연산자가 숫자나 문자열을 요구할 때마다 캐스트를 마구잡이로 넣었습니다. 그 캐스트는 실패할 수 있습니다. 사용자 코드가 잘못되었더라도, 사용 가능한 언어를 만들려면 그 오류를 우아하게 처리해야 할 책임이 있습니다.

이제 런타임 에러에 대해 이야기할 시간입니다. 이전 장들에서 오류 처리에 대해 많은 설명을 했지만, 그것들은 모두 구문(syntax) 또는 정적(static) 오류였습니다. 이러한 오류들은 어떤 코드도 실행되기 전에 감지되고 보고됩니다. 런타임 에러는 프로그램이 실행되는 동안 언어 의미론이 감지하고 보고할 것을 요구하는 오류입니다(그래서 이름이 그렇게 붙었습니다).

지금 현재, 피연산자가 수행되는 연산에 잘못된 타입인 경우, Java 캐스트는 실패하고 JVM은 ClassCastException을 던질 것입니다. 이는 전체 스택을 해제하고 애플리케이션을 종료시키며, Java 스택 트레이스를 사용자에게 쏟아냅니다. 이것은 우리가 원하는 바가 아닐 겁니다. Lox가 Java로 구현되었다는 사실은 사용자에게 숨겨져야 할 세부 사항입니다. 대신, 우리는 사용자에게 Lox 런타임 에러가 발생했음을 이해시키고, 우리 언어와 그들의 프로그램에 관련된 에러 메시지를 제공하고 싶습니다.

하지만 Java의 동작 방식에는 한 가지 장점이 있습니다. 오류가 발생했을 때 모든 코드 실행을 올바르게 중단한다는 점입니다. 예를 들어 사용자가 다음과 같은 표현식을 입력했다고 가정해 봅시다.

2 * (3 / -"muffin")

머핀을 부정할 수는 없으므로, 내부 - 표현식에서 런타임 오류를 보고해야 합니다. 이는 의미 있는 우측 피연산자가 없으므로 / 표현식을 평가할 수 없다는 것을 의미합니다. *도 마찬가지입니다. 따라서 어떤 표현식 깊숙한 곳에서 런타임 오류가 발생하면, 모든 단계를 빠져나와야 합니다.

런타임 에러를 출력한 다음 프로세스를 중단하고 애플리케이션을 완전히 종료할 수도 있습니다. 이것은 어떤 멜로드라마적인 분위기를 풍깁니다. 프로그래밍 언어 인터프리터의 마이크 드롭과 같은 것이죠.

유혹적이지만, 우리는 좀 더 재앙적이지 않은 일을 해야 할 것입니다. 런타임 에러가 표현식 평가를 중단시켜야 하지만, 인터프리터를 종료시켜서는 안 됩니다. 사용자가 REPL을 실행하다가 코드 한 줄에 오타가 있더라도, 세션을 계속 유지하고 그 후에 더 많은 코드를 입력할 수 있어야 합니다.

7 . 3 . 1런타임 에러 감지하기

우리의 트리 워크 인터프리터는 재귀적인 메서드 호출을 사용하여 중첩된 표현식을 평가하며, 이 모든 호출에서 풀려나와야 합니다. Java에서 예외를 던지는 것은 이를 달성하기에 좋은 방법입니다. 하지만 Java 자체의 캐스트 실패 대신, 우리가 원하는 방식으로 처리할 수 있도록 Lox에 특화된 예외를 정의할 것입니다.

캐스트를 수행하기 전에 객체의 타입을 직접 확인합니다. 따라서 단항 -의 경우, 다음을 추가합니다.

      case MINUS:
lox/Interpreter.java
visitUnaryExpr() 내
        checkNumberOperand(expr.operator, right);
        return -(double)right;
lox/Interpreter.java, visitUnaryExpr() 내

피연산자를 확인하는 코드는 다음과 같습니다.

lox/Interpreter.java
visitUnaryExpr() 다음에 추가
  private void checkNumberOperand(Token operator, Object operand) {
    if (operand instanceof Double) return;
    throw new RuntimeError(operator, "Operand must be a number.");
  }
lox/Interpreter.java, visitUnaryExpr() 다음에 추가

검사가 실패하면 다음 중 하나를 던집니다.

lox/RuntimeError.java
새 파일 생성
package com.craftinginterpreters.lox;

class RuntimeError extends RuntimeException {
  final Token token;

  RuntimeError(Token token, String message) {
    super(message);
    this.token = token;
  }
}
lox/RuntimeError.java, 새 파일 생성

Java의 캐스트 예외와 달리, 우리 클래스는 런타임 에러가 사용자 코드의 어느 부분에서 발생했는지 식별하는 토큰을 추적합니다. 정적 에러와 마찬가지로, 이는 사용자가 코드를 어디서 수정해야 할지 아는 데 도움이 됩니다.

이항 연산자에도 유사한 검사가 필요합니다. 인터프리터를 구현하는 데 필요한 모든 코드를 약속드렸으니, 전부 다 살펴보겠습니다.

보다 큼:

      case GREATER:
lox/Interpreter.java
visitBinaryExpr() 내
        checkNumberOperands(expr.operator, left, right);
        return (double)left > (double)right;
lox/Interpreter.java, visitBinaryExpr() 내

크거나 같음:

      case GREATER_EQUAL:
lox/Interpreter.java
visitBinaryExpr() 내
        checkNumberOperands(expr.operator, left, right);
        return (double)left >= (double)right;
lox/Interpreter.java, visitBinaryExpr() 내

보다 작음:

      case LESS:
lox/Interpreter.java
visitBinaryExpr() 내
        checkNumberOperands(expr.operator, left, right);
        return (double)left < (double)right;
lox/Interpreter.java, visitBinaryExpr() 내

작거나 같음:

      case LESS_EQUAL:
lox/Interpreter.java
visitBinaryExpr() 내
        checkNumberOperands(expr.operator, left, right);
        return (double)left <= (double)right;
lox/Interpreter.java, visitBinaryExpr() 내

뺄셈:

      case MINUS:
lox/Interpreter.java
visitBinaryExpr() 내
        checkNumberOperands(expr.operator, left, right);
        return (double)left - (double)right;
lox/Interpreter.java, visitBinaryExpr() 내

나눗셈:

      case SLASH:
lox/Interpreter.java
visitBinaryExpr() 내
        checkNumberOperands(expr.operator, left, right);
        return (double)left / (double)right;
lox/Interpreter.java, visitBinaryExpr() 내

곱셈:

      case STAR:
lox/Interpreter.java
visitBinaryExpr() 내
        checkNumberOperands(expr.operator, left, right);
        return (double)left * (double)right;
lox/Interpreter.java, visitBinaryExpr() 내

이 모든 것들은 이 검증기에 의존하는데, 이 검증기는 단항 연산자용과 거의 동일합니다.

lox/Interpreter.java
checkNumberOperand() 다음에 추가
  private void checkNumberOperands(Token operator,
                                   Object left, Object right) {
    if (left instanceof Double && right instanceof Double) return;
    
    throw new RuntimeError(operator, "Operands must be numbers.");
  }
lox/Interpreter.java, checkNumberOperand() 다음에 추가

마지막으로 남은 연산자는, 역시나 특이한 덧셈입니다. +는 숫자와 문자열에 대해 오버로드되어 있으므로, 이미 타입을 확인하는 코드가 있습니다. 우리가 해야 할 일은 두 가지 성공 케이스 중 어느 쪽도 일치하지 않으면 실패시키는 것입니다.

          return (String)left + (String)right;
        }

lox/Interpreter.java
visitBinaryExpr() 내
1줄 대체
        throw new RuntimeError(expr.operator,
            "Operands must be two numbers or two strings.");
      case SLASH:
lox/Interpreter.java, visitBinaryExpr() 내, 1줄 대체

이제 평가기 깊숙한 곳에서 런타임 에러를 감지할 수 있게 되었습니다. 에러가 던져지고 있습니다. 다음 단계는 이 에러들을 잡아내는 코드를 작성하는 것입니다. 이를 위해 Interpreter 클래스를 주 Lox 클래스에 연결해야 합니다.

7 . 4인터프리터 연결하기

방문(visit) 메서드는 인터프리터 클래스의 핵심이며, 실제 작업이 일어나는 곳입니다. 나머지 프로그램과의 인터페이스를 위해 이 메서드들을 감싸는 역할을 해야 합니다. 인터프리터의 공개 API는 간단히 하나의 메서드입니다.

lox/Interpreter.java
Interpreter 클래스 내
  void interpret(Expr expression) { 
    try {
      Object value = evaluate(expression);
      System.out.println(stringify(value));
    } catch (RuntimeError error) {
      Lox.runtimeError(error);
    }
  }
lox/Interpreter.java, Interpreter 클래스 내

이 메서드는 표현식에 대한 구문 트리를 받아 평가합니다. 성공하면 evaluate()는 결과 값에 해당하는 객체를 반환합니다. interpret()는 그 객체를 문자열로 변환하여 사용자에게 보여줍니다. Lox 값을 문자열로 변환하기 위해 다음을 사용합니다:

lox/Interpreter.java
isEqual() 다음에 추가
  private String stringify(Object object) {
    if (object == null) return "nil";

    if (object instanceof Double) {
      String text = object.toString();
      if (text.endsWith(".0")) {
        text = text.substring(0, text.length() - 2);
      }
      return text;
    }

    return object.toString();
  }
lox/Interpreter.java, isEqual() 다음에 추가

이것은 isTruthy()와 같이 Lox 객체에 대한 사용자 관점과 Java 내부 표현 사이의 경계를 넘나드는 또 다른 코드 조각입니다.

상당히 간단합니다. Lox는 Java 개발자에게 익숙하도록 설계되었으므로, 불리언과 같은 것들은 두 언어에서 동일하게 보입니다. 두 가지 예외 케이스는 Java의 null을 사용하여 표현하는 nil과 숫자입니다.

Lox는 정수 값에도 배정밀도 부동 소수점 숫자를 사용합니다. 이 경우, 소수점 없이 출력되어야 합니다. Java는 부동 소수점과 정수 타입을 모두 가지고 있으므로, 어떤 타입을 사용하고 있는지 알려주고 싶어 합니다. 정수 값을 가지는 double 타입에 명시적으로 .0을 추가하여 알려줍니다. 우리는 그것에 신경 쓰지 않으므로, 끝부분을 제거합니다.

7 . 4 . 1런타임 에러 보고하기

표현식을 평가하는 동안 런타임 에러가 발생하면, interpret()가 이를 포착합니다. 이를 통해 사용자에게 에러를 보고하고 우아하게 계속 진행할 수 있습니다. 기존의 모든 에러 보고 코드는 Lox 클래스에 있으므로, 이 메서드도 그곳에 추가합니다:

lox/Lox.java
error() 다음에 추가
  static void runtimeError(RuntimeError error) {
    System.err.println(error.getMessage() +
        "\n[line " + error.token.line + "]");
    hadRuntimeError = true;
  }
lox/Lox.java, error() 다음에 추가

우리는 RuntimeError와 연결된 토큰을 사용하여 에러가 발생했을 때 어떤 코드 줄이 실행 중이었는지 사용자에게 알려줍니다. 더 좋은 방법은 사용자에게 전체 호출 스택을 제공하여 그 코드를 어떻게 실행하게 되었는지 보여주는 것입니다. 하지만 아직 함수 호출이 없으므로, 이에 대해 걱정할 필요는 없을 것 같습니다.

에러를 표시한 후, runtimeError()는 이 필드를 설정합니다.

  static boolean hadError = false;
lox/Lox.java
Lox 클래스 내
  static boolean hadRuntimeError = false;

  public static void main(String[] args) throws IOException {
lox/Lox.java, Lox 클래스 내

그 필드는 작지만 중요한 역할을 합니다.

    run(new String(bytes, Charset.defaultCharset()));

    // Indicate an error in the exit code.
    if (hadError) System.exit(65);
lox/Lox.java
runFile() 내
    if (hadRuntimeError) System.exit(70);
  }
lox/Lox.java, runFile() 내

사용자가 파일에서 Lox 스크립트를 실행하는 도중 런타임 에러가 발생하면, 프로세스가 종료될 때 호출 프로세스에 알리기 위해 종료 코드를 설정합니다. 모든 사람이 셸 예절에 신경 쓰는 것은 아니지만, 우리는 신경 씁니다.

7 . 4 . 2인터프리터 실행하기

이제 인터프리터가 생겼으니, Lox 클래스가 이를 사용하기 시작할 수 있습니다.

public class Lox {
lox/Lox.java
Lox 클래스 내
  private static final Interpreter interpreter = new Interpreter();
  static boolean hadError = false;
lox/Lox.java, Lox 클래스 내

이 필드를 `static`으로 선언하는 이유는 REPL 세션 내에서 run()에 대한 연속적인 호출이 동일한 인터프리터를 재사용하도록 하기 위함입니다. 지금은 차이가 없지만, 나중에 인터프리터가 전역 변수를 저장할 때 중요해집니다. 이 변수들은 REPL 세션 내내 유지되어야 합니다.

마지막으로, 지난 장에서 구문 트리를 출력하기 위해 사용했던 임시 코드 줄을 제거하고 다음으로 대체합니다:

    // Stop if there was a syntax error.
    if (hadError) return;

lox/Lox.java
run() 내
1줄 대체
    interpreter.interpret(expression);
  }
lox/Lox.java, run() 내, 1줄 대체

이제 스캐닝, 파싱, 실행을 포함하는 완전한 언어 파이프라인을 갖게 되었습니다. 축하합니다. 이제 당신만의 산술 계산기를 가지게 되었습니다.

보시다시피, 인터프리터는 아직 기본적인 기능만 갖추고 있습니다. 하지만 오늘 우리가 설정한 Interpreter 클래스와 방문자 패턴은 나중 장에서 변수, 함수 등 흥미로운 내용으로 채워질 뼈대를 형성합니다. 지금 당장은 인터프리터가 많은 일을 하지는 않지만, 살아 움직이고 있습니다!

안녕하고 손을 흔드는 해골.

도전 과제

  1. 숫자 이외의 타입에 대한 비교를 허용하는 것은 유용할 수 있습니다. 연산자들이 문자열에 대해 합리적인 해석을 가질 수도 있습니다. 심지어 3 < "pancake"와 같은 혼합 타입 간의 비교는 이질적인 타입의 정렬된 컬렉션과 같은 기능을 활성화하는 데 유용할 수 있습니다. 또는 단순히 버그와 혼란으로 이어질 수도 있습니다.

    Lox를 확장하여 다른 타입의 비교를 지원하시겠습니까? 그렇다면 어떤 타입 쌍을 허용하고 그 순서를 어떻게 정의하시겠습니까? 선택을 정당화하고 다른 언어와 비교해 보세요.

  2. 많은 언어에서 + 연산자를 정의할 때 어느 한쪽 피연산자가 문자열이면 다른 쪽도 문자열로 변환하여 결과를 연결합니다. 예를 들어, "scone" + 4scone4를 생성합니다. visitBinaryExpr()의 코드를 확장하여 이를 지원해 보세요.

  3. 지금 현재 숫자를 0으로 나누면 무슨 일이 발생합니까? 어떻게 되어야 한다고 생각하십니까? 선택을 정당화하십시오. 당신이 아는 다른 언어들은 0으로 나누는 것을 어떻게 처리하며, 왜 그런 선택을 하는가요?

    visitBinaryExpr()의 구현을 변경하여 이 경우에 런타임 에러를 감지하고 보고하도록 하세요.

설계 노트: 정적 타이핑과 동적 타이핑

Java와 같은 일부 언어는 정적 타이핑(statically typed)되어 있어, 코드가 실행되기 전 컴파일 시간에 타입 오류가 감지되고 보고됩니다. Lox와 같은 다른 언어는 동적 타이핑(dynamically typed)되어 있어, 타입 오류 검사를 작업이 시도되기 직전 런타임까지 미룹니다. 우리는 이를 흑백 논리로 생각하는 경향이 있지만, 실제로는 그 사이에 연속적인 스펙트럼이 존재합니다.

대부분의 정적 타이핑 언어조차도 런타임에 일부 타입 검사를 수행한다는 것이 밝혀졌습니다. 타입 시스템은 대부분의 타입 규칙을 정적으로 검사하지만, 다른 연산들을 위해 생성된 코드에 런타임 검사를 삽입합니다.

예를 들어, Java에서 정적 타입 시스템은 캐스트 표현식이 항상 안전하게 성공할 것이라고 가정합니다. 어떤 값을 캐스트한 후에는, 이를 대상 타입으로 정적으로 처리하고 컴파일 오류를 얻지 않습니다. 하지만 다운캐스트는 명백히 실패할 수 있습니다. 정적 검사기가 언어의 건전성 보장을 위반하지 않고 캐스트가 항상 성공한다고 가정할 수 있는 유일한 이유는, 캐스트가 런타임에 검사되고 실패 시 예외를 던지기 때문입니다.

더 미묘한 예시는 Java와 C#의 공변 배열(covariant arrays)입니다. 배열에 대한 정적 서브타이핑 규칙은 건전하지 않은 연산을 허용합니다. 다음을 생각해 보세요.

Object[] stuff = new Integer[1];
stuff[0] = "not an int!";

이 코드는 어떤 오류 없이 컴파일됩니다. 첫 번째 줄은 Integer 배열을 업캐스트하여 Object 배열 타입의 변수에 저장합니다. 두 번째 줄은 문자열을 배열의 한 셀에 저장합니다. Object 배열 타입은 정적으로 이를 허용합니다—문자열은 Object 입니다—그러나 런타임에 stuff가 참조하는 실제 Integer 배열에는 문자열이 들어 있어서는 안 됩니다! 이러한 재앙을 피하기 위해, 배열에 값을 저장할 때 JVM은 허용되는 타입인지 확인하기 위해 런타임 검사를 수행합니다. 그렇지 않으면 ArrayStoreException을 던집니다.

Java는 첫 번째 줄의 캐스트를 허용하지 않음으로써 런타임에 이를 검사할 필요를 피할 수 있었습니다. Integer 배열이 Object 배열이 아니도록 배열을 불변(invariant)으로 만들 수 있습니다. 이는 정적으로 건전하지만, 배열에서 읽기만 하는 일반적이고 안전한 코드 패턴을 금지합니다. 공변성은 배열에 쓰기를 전혀 하지 않는다면 안전합니다. 이러한 패턴은 Java 1.0이 제네릭을 지원하기 전에는 유용성 면에서 특히 중요했습니다. James Gosling과 다른 Java 설계자들은 약간의 정적 안전성과 성능(배열 저장 검사는 시간이 걸립니다)을 유연성과 교환했습니다.

어딘가에서 그러한 트레이드오프를 하지 않는 현대 정적 타이핑 언어는 거의 없습니다. 심지어 Haskell조차도 비완전 매칭(non-exhaustive matches)이 있는 코드를 실행하게 할 것입니다. 정적 타이핑 언어를 설계하고 있다면, 일부 타입 검사를 런타임까지 미룸으로써 정적 안전성의 이점을 너무 많이 희생하지 않고도 사용자에게 더 많은 유연성을 제공할 수 있다는 점을 명심하십시오.

반면에, 사용자들이 정적 타이핑 언어를 선택하는 주요 이유 중 하나는 프로그램 실행 시 특정 종류의 오류가 절대 발생하지 않을 것이라는 언어가 주는 신뢰감 때문입니다. 너무 많은 타입 검사를 런타임까지 미루면, 그 신뢰가 훼손됩니다.