8

문장과 상태

내 평생, 내 마음은 이름을 알 수 없는 어떤 것을 갈망했다. 앙드레 브르통, 광적인 사랑

지금까지 우리가 만든 인터프리터는 실제 언어를 프로그래밍하는 느낌보다는 계산기 버튼을 누르는 것에 가깝습니다. 저에게 '프로그래밍'이란 작은 조각들을 모아 시스템을 구축하는 것을 의미합니다. 아직 이름과 데이터나 함수를 연결할 방법이 없기 때문에 그렇게 할 수 없습니다. 각 부분을 참조할 방법이 없으면 소프트웨어를 구성할 수 없습니다.

바인딩을 지원하려면 인터프리터에 내부 상태가 필요합니다. 프로그램 시작 부분에서 변수를 정의하고 끝에서 사용한다면, 인터프리터는 그 변수의 값을 그동안 가지고 있어야 합니다. 그래서 이 장에서는 인터프리터가 단순히 처리하는 것을 넘어 *기억*할 수 있는 두뇌를 부여할 것입니다.

무언가를 기억하는 뇌의 모습.

상태와 문장은 밀접하게 연결되어 있습니다. 문장은 정의상 값으로 평가되지 않으므로, 유용하려면 다른 일을 해야 합니다. 이 다른 일을 **부수 효과(side effect)**라고 합니다. 이는 사용자에게 보이는 출력을 생성하거나, 나중에 감지될 수 있도록 인터프리터의 특정 상태를 수정하는 것을 의미할 수 있습니다. 후자는 변수나 다른 명명된 엔티티를 정의하는 데 아주 적합합니다.

이 장에서는 이 모든 것을 다룰 것입니다. 출력을 생성하는 문장(`print`)과 상태를 생성하는 문장(`var`)을 정의할 것입니다. 변수에 접근하고 할당하는 표현식을 추가할 것입니다. 마지막으로 블록과 지역 스코프를 추가할 것입니다. 한 장에 많은 내용을 담아야 하지만, 차근차근 해결해 나갈 것입니다.

8 . 1문장

Lox 문법에 문장(statement)을 추가하는 것으로 시작합니다. 문장은 표현식과 크게 다르지 않습니다. 가장 간단한 두 가지 유형부터 시작하겠습니다.

  1. 표현식 문장(expression statement)은 문장이 필요한 곳에 표현식을 배치할 수 있게 합니다. 이는 부수 효과(side effect)를 가진 표현식을 평가하기 위해 존재합니다. 여러분은 인지하지 못할 수도 있지만, C, Java 및 다른 언어에서 항상 사용하고 있습니다. 함수나 메서드 호출 뒤에 `;`가 붙어 있는 것을 볼 때마다 그것이 바로 표현식 문장입니다.

  2. `print` 문장은 표현식을 평가하고 그 결과를 사용자에게 보여줍니다. 인쇄 기능을 라이브러리 함수로 만드는 대신 언어에 직접 내장하는 것이 이상하다는 점을 인정합니다. 그렇게 하는 것은 우리가 이 인터프리터를 한 장씩 만들고 있으며, 모든 작업이 완료되기 전에 가지고 놀고 싶다는 점을 고려한 것입니다. `print`를 라이브러리 함수로 만들려면, 함수 정의 및 호출을 위한 모든 메커니즘을 갖출 때까지 기다려야 어떤 부수 효과도 확인할 수 있을 것입니다.

새로운 문법은 새로운 문법 규칙을 의미합니다. 이 장에서는 마침내 전체 Lox 스크립트를 파싱할 수 있게 됩니다. Lox는 명령형(imperative), 동적 타입(dynamically typed) 언어이므로, 스크립트의 "최상위(top level)"는 단순히 문장들의 목록입니다. 새로운 규칙은 다음과 같습니다.

programstatement* EOF ;

statementexprStmt
               | printStmt ;

exprStmtexpression ";" ;
printStmt"print" expression ";" ;

이제 첫 번째 규칙은 `program`이며, 이는 문법의 시작점이자 완전한 Lox 스크립트 또는 REPL 항목을 나타냅니다. 프로그램은 문장 목록 뒤에 특수한 "파일 끝(end of file)" 토큰이 오는 형태입니다. 이 필수적인 끝 토큰은 파서가 전체 입력을 소비하고 스크립트 끝에 있는 잘못된 소비되지 않은 토큰을 조용히 무시하지 않도록 보장합니다.

현재 `statement`는 우리가 설명한 두 가지 종류의 문장에 대한 두 가지 경우만을 가지고 있습니다. 이 장과 다음 장에서 더 많은 내용을 채울 것입니다. 다음 단계는 이 문법을 메모리에 저장할 수 있는 형태, 즉 문법 트리(syntax trees)로 바꾸는 것입니다.

8 . 1 . 1문장 구문 트리

문법에서 표현식과 문장이 모두 허용되는 곳은 없습니다. 예를 들어 `+` 연산자의 피연산자는 항상 표현식이며, 문장이 될 수 없습니다. `while` 루프의 본문은 항상 문장입니다.

두 문법이 서로 분리되어 있기 때문에, 이들이 모두 상속하는 단일 기본 클래스가 필요하지 않습니다. 표현식과 문장을 별도의 클래스 계층으로 분리하면 Java 컴파일러가 표현식을 예상하는 Java 메서드에 문장을 전달하는 것과 같은 어리석은 실수를 찾는 데 도움을 줄 수 있습니다.

이는 문장을 위한 새로운 기본 클래스를 의미합니다. 선배들이 그랬던 것처럼, 우리는 "Stmt"라는 암호 같은 이름을 사용할 것입니다. 원대한 선견지명으로 저는 이것을 예상하고 작은 AST 메타 프로그래밍 스크립트를 설계했습니다. 그래서 `defineAst()`에 "Expr"를 매개변수로 전달했습니다. 이제 Stmt와 그 서브클래스를 정의하기 위해 또 다른 호출을 추가합니다.

      "Unary    : Token operator, Expr right"
    ));
tool/GenerateAst.java
in main()

    defineAst(outputDir, "Stmt", Arrays.asList(
      "Expression : Expr expression",
      "Print      : Expr expression"
    ));
  }
tool/GenerateAst.java, in main()

AST 생성기 스크립트를 실행하고 결과로 생성된 "Stmt.java" 파일을 확인하십시오. 이 파일에는 표현식 및 `print` 문장에 필요한 구문 트리 클래스들이 포함되어 있습니다. 파일을 IDE 프로젝트나 makefile 등에 추가하는 것을 잊지 마십시오.

8 . 1 . 2문장 파싱하기

파서의 `parse()` 메서드는 단일 표현식을 파싱하고 반환했는데, 이는 지난 장을 작동시키기 위한 임시방편이었습니다. 이제 문법에 올바른 시작 규칙인 `program`이 생겼으니, `parse()`를 제대로 된 기능으로 바꿀 수 있습니다.

lox/Parser.java
method parse()
replace 7 lines
  List<Stmt> parse() {
    List<Stmt> statements = new ArrayList<>();
    while (!isAtEnd()) {
      statements.add(statement());
    }

    return statements; 
  }
lox/Parser.java, method parse(), replace 7 lines

이 코드는 입력의 끝에 도달할 때까지 찾을 수 있는 모든 문장을 파싱합니다. 이는 `program` 규칙을 재귀 하강(recursive descent) 방식으로 거의 직접적으로 번역한 것입니다. 이제 ArrayList를 사용하므로, Java의 장황함에 대한 작은 기도를 올려야 할 것입니다.

package com.craftinginterpreters.lox;

lox/Parser.java
import java.util.ArrayList;
import java.util.List;
lox/Parser.java

프로그램은 문장 목록이며, 이 메서드를 사용하여 그 문장 중 하나를 파싱합니다.

lox/Parser.java
add after expression()
  private Stmt statement() {
    if (match(PRINT)) return printStatement();

    return expressionStatement();
  }
lox/Parser.java, add after expression()

아직은 매우 기본적인 형태이지만, 나중에 더 많은 문장 유형으로 채울 것입니다. 현재 토큰을 확인하여 어떤 특정 문장 규칙이 일치하는지 결정합니다. `print` 토큰은 명백히 `print` 문장을 의미합니다.

다음 토큰이 알려진 문장 유형처럼 보이지 않으면, 표현식 문장이라고 가정합니다. 이는 문장을 파싱할 때 일반적인 최종 폴백(fallthrough) 케이스입니다. 왜냐하면 표현식을 첫 번째 토큰만으로 미리 인식하기 어렵기 때문입니다.

각 문장 유형은 자체 메서드를 가집니다. 먼저 `print`:

lox/Parser.java
add after statement()
  private Stmt printStatement() {
    Expr value = expression();
    consume(SEMICOLON, "Expect ';' after value.");
    return new Stmt.Print(value);
  }
lox/Parser.java, add after statement()

`print` 토큰 자체는 이미 매칭하고 소비했으므로, 여기서는 그럴 필요가 없습니다. 우리는 뒤따르는 표현식을 파싱하고, 종료 세미콜론을 소비한 다음, 구문 트리를 생성합니다.

만약 `print` 문장과 일치하지 않았다면, 다음 중 하나가 있어야 합니다.

lox/Parser.java
add after printStatement()
  private Stmt expressionStatement() {
    Expr expr = expression();
    consume(SEMICOLON, "Expect ';' after expression.");
    return new Stmt.Expression(expr);
  }
lox/Parser.java, add after printStatement()

이전 메서드와 유사하게, 우리는 세미콜론 뒤에 오는 표현식을 파싱합니다. 해당 Expr을 올바른 유형의 Stmt로 감싸서 반환합니다.

8 . 1 . 3문장 실행하기

우리는 지난 몇 개 장을 축소판으로 빠르게 훑으며 프런트엔드를 진행하고 있습니다. 이제 파서는 문장 구문 트리를 생성할 수 있으므로, 다음이자 마지막 단계는 이를 해석하는 것입니다. 표현식에서처럼 방문자 패턴(Visitor pattern)을 사용하지만, 문장에는 자체 기본 클래스가 있으므로 구현해야 할 새로운 방문자 인터페이스인 Stmt.Visitor가 있습니다.

Interpreter가 구현하는 인터페이스 목록에 이를 추가합니다.

lox/Interpreter.java
replace 1 line
class Interpreter implements Expr.Visitor<Object>,
                             Stmt.Visitor<Void> {
  void interpret(Expr expression) { 
lox/Interpreter.java, replace 1 line

표현식과 달리 문장은 값을 생성하지 않으므로, 방문(visit) 메서드의 반환 타입은 Object가 아닌 Void입니다. 우리는 두 가지 문장 유형을 가지고 있으며, 각각에 대한 방문 메서드가 필요합니다. 가장 쉬운 것은 표현식 문장입니다.

lox/Interpreter.java
add after evaluate()
  @Override
  public Void visitExpressionStmt(Stmt.Expression stmt) {
    evaluate(stmt.expression);
    return null;
  }
lox/Interpreter.java, add after evaluate()

우리는 기존의 `evaluate()` 메서드를 사용하여 내부 표현식을 평가하고 그 값은 버립니다. 그리고 `null`을 반환합니다. Java는 특수한 대문자 Void 반환 타입을 충족시키기 위해 이렇게 요구합니다. 이상하지만, 어쩌겠습니까?

`print` 문장의 방문 메서드도 크게 다르지 않습니다.

lox/Interpreter.java
add after visitExpressionStmt()
  @Override
  public Void visitPrintStmt(Stmt.Print stmt) {
    Object value = evaluate(stmt.expression);
    System.out.println(stringify(value));
    return null;
  }
lox/Interpreter.java, add after visitExpressionStmt()

표현식의 값을 버리기 전에, 지난 장에서 소개했던 `stringify()` 메서드를 사용하여 문자열로 변환한 다음 표준 출력(stdout)으로 출력합니다.

이제 우리 인터프리터는 문장을 방문할 수 있지만, 문장을 인터프리터에 전달하기 위해 해야 할 일이 좀 있습니다. 먼저, Interpreter 클래스의 기존 `interpret()` 메서드를 수정하여 문장 목록, 즉 프로그램을 받도록 변경합니다.

lox/Interpreter.java
method interpret()
replace 8 lines
  void interpret(List<Stmt> statements) {
    try {
      for (Stmt statement : statements) {
        execute(statement);
      }
    } catch (RuntimeError error) {
      Lox.runtimeError(error);
    }
  }
lox/Interpreter.java, method interpret(), replace 8 lines

이는 단일 표현식을 받던 이전 코드를 대체합니다. 새 코드는 이 작은 헬퍼 메서드에 의존합니다.

lox/Interpreter.java
add after evaluate()
  private void execute(Stmt stmt) {
    stmt.accept(this);
  }
lox/Interpreter.java, add after evaluate()

이는 표현식을 위한 `evaluate()` 메서드와 유사한 문장용 메서드입니다. 이제 목록을 다루므로, Java에게 이를 알려야 합니다.

package com.craftinginterpreters.lox;
lox/Interpreter.java

import java.util.List;

class Interpreter implements Expr.Visitor<Object>,
lox/Interpreter.java

주요 Lox 클래스는 여전히 단일 표현식을 파싱하여 인터프리터에 전달하려고 합니다. 파싱 라인을 다음과 같이 수정합니다.

    Parser parser = new Parser(tokens);
lox/Lox.java
in run()
replace 1 line
    List<Stmt> statements = parser.parse();

    // Stop if there was a syntax error.
lox/Lox.java, in run(), replace 1 line

그리고 인터프리터 호출을 다음과 같이 대체합니다.

    if (hadError) return;

lox/Lox.java
in run()
replace 1 line
    interpreter.interpret(statements);
  }
lox/Lox.java, in run(), replace 1 line

기본적으로 새로운 문법을 연결하는 작업입니다. 이제 인터프리터를 실행하고 시도해봅시다. 이 시점에서 스크립트로 실행할 작은 Lox 프로그램을 텍스트 파일에 작성해보는 것이 좋습니다. 예를 들어 다음과 같이 말이죠.

print "one";
print true;
print 2 + 1;

거의 실제 프로그램처럼 보입니다! REPL도 이제 단순한 표현식 대신 완전한 문장을 입력해야 한다는 점에 유의하세요. 세미콜론을 잊지 마세요.

8 . 2전역 변수

이제 문장이 있으니, 상태에 대해 작업할 수 있습니다. 렉시컬 스코핑의 모든 복잡성에 들어가기 전에, 가장 쉬운 종류의 변수인 전역 변수부터 시작하겠습니다. 두 가지 새로운 구성이 필요합니다.

  1. 변수 선언(variable declaration) 문장은 새로운 변수를 생성합니다.

    var beverage = "espresso";
    

    이는 이름(여기서는 "beverage")을 값(여기서는 문자열 `"espresso"`)과 연결하는 새로운 바인딩을 생성합니다.

  2. 이 작업이 완료되면, 변수 표현식(variable expression)은 해당 바인딩에 접근합니다. 식별자 "beverage"가 표현식으로 사용될 때, 해당 이름에 바인딩된 값을 찾아 반환합니다.

    print beverage; // "espresso".
    

나중에 할당과 블록 스코프를 추가할 것이지만, 일단 이 정도면 시작하기에 충분합니다.

8 . 2 . 1변수 문법

이전과 마찬가지로, 문법부터 시작하여 구현을 앞에서 뒤로 진행할 것입니다. 변수 선언은 문장이지만, 다른 문장과는 다르며, 이들을 처리하기 위해 문장 문법을 두 개로 나눌 것입니다. 이는 문법이 특정 종류의 문장이 허용되는 위치를 제한하기 때문입니다.

제어 흐름 문장—`if` 문의 then 및 else 분기 또는 `while` 루프의 본문과 같은—의 절은 각각 단일 문장입니다. 하지만 그 문장은 이름을 선언하는 문장이 될 수 없습니다. 다음은 괜찮습니다.

if (monday) print "Ugh, already?";

하지만 이것은 안 됩니다.

if (monday) var beverage = "espresso";

후자를 허용할 *수도* 있지만, 혼란스럽습니다. 저 `beverage` 변수의 스코프는 무엇일까요? `if` 문장 이후에도 유지될까요? 그렇다면 월요일이 아닌 날에는 그 값이 무엇일까요? 애초에 그런 날에는 변수가 존재하기는 할까요?

이런 코드는 이상하므로 C, Java 등에서는 모두 이를 허용하지 않습니다. 마치 문장에 두 단계의 "우선순위"가 있는 것과 같습니다. 블록 내부나 최상위 레벨과 같이 문장이 허용되는 일부 위치에서는 선언을 포함한 모든 종류의 문장을 허용합니다. 다른 위치에서는 이름을 선언하지 않는 "더 높은" 우선순위의 문장만 허용합니다.

이러한 구분을 수용하기 위해, 이름을 선언하는 종류의 문장을 위한 또 다른 규칙을 추가합니다.

programdeclaration* EOF ;

declarationvarDecl
               | statement ;

statementexprStmt
               | printStmt ;

선언 문장은 새로운 `declaration` 규칙 아래로 들어갑니다. 지금은 변수뿐이지만, 나중에는 함수와 클래스도 포함될 것입니다. 선언이 허용되는 모든 곳에서는 비선언 문장(non-declaring statements)도 허용되므로, `declaration` 규칙은 `statement`로 이어집니다. 당연히 스크립트의 최상위 수준에서 선언을 할 수 있으므로, `program`은 새로운 규칙으로 연결됩니다.

변수를 선언하는 규칙은 다음과 같습니다.

varDecl"var" IDENTIFIER ( "=" expression )? ";" ;

대부분의 문장처럼, 선행 키워드로 시작합니다. 이 경우 `var`입니다. 그 다음에는 선언될 변수의 이름을 나타내는 식별자 토큰이 오고, 이어서 선택적인 초기화 표현식이 옵니다. 마지막으로 세미콜론으로 마무리합니다.

변수에 접근하기 위해, 새로운 종류의 기본 표현식을 정의합니다.

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

이 `IDENTIFIER` 절은 단일 식별자 토큰과 일치하며, 이는 접근하려는 변수의 이름으로 이해됩니다.

이 새로운 문법 규칙들은 해당 구문 트리를 얻습니다. AST 생성기에서 변수 선언을 위한 새로운 문장 노드를 추가합니다.

      "Expression : Expr expression",
      "Print      : Expr expression",
tool/GenerateAst.java
in main()
add “,” to previous line
      "Var        : Token name, Expr initializer"
    ));
tool/GenerateAst.java, in main(), add “,” to previous line

이 노드는 선언하는 대상이 무엇인지 알 수 있도록 이름 토큰과 초기화 표현식을 저장합니다. (초기화 표현식이 없으면 해당 필드는 `null`입니다.)

그런 다음 변수에 접근하기 위한 표현식 노드를 추가합니다.

      "Literal  : Object value",
      "Unary    : Token operator, Expr right",
tool/GenerateAst.java
in main()
add “,” to previous line
      "Variable : Token name"
    ));
tool/GenerateAst.java, in main(), add “,” to previous line

이는 변수 이름에 대한 토큰을 감싸는 단순한 래퍼입니다. 그게 전부입니다. 항상 그렇듯이, 업데이트된 "Expr.java" 및 "Stmt.java" 파일을 얻으려면 AST 생성기 스크립트를 실행하는 것을 잊지 마십시오.

8 . 2 . 2변수 파싱하기

변수 문장을 파싱하기 전에, 문법의 새로운 `declaration` 규칙을 위한 공간을 마련하기 위해 일부 코드를 옮겨야 합니다. 이제 프로그램의 최상위는 선언 목록이므로, 파서의 진입점(entrypoint) 메서드가 변경됩니다.

  List<Stmt> parse() {
    List<Stmt> statements = new ArrayList<>();
    while (!isAtEnd()) {
lox/Parser.java
in parse()
replace 1 line
      statements.add(declaration());
    }

    return statements; 
  }
lox/Parser.java, in parse(), replace 1 line

이는 다음 새로운 메서드를 호출합니다.

lox/Parser.java
add after expression()
  private Stmt declaration() {
    try {
      if (match(VAR)) return varDeclaration();

      return statement();
    } catch (ParseError error) {
      synchronize();
      return null;
    }
  }
lox/Parser.java, add after expression()

이전 장에서 오류 복구를 위한 인프라를 구축했던 것을 기억하시나요? 이제 드디어 그것을 연결할 준비가 되었습니다.

이 `declaration()` 메서드는 블록이나 스크립트에서 일련의 문장을 파싱할 때 반복적으로 호출하는 메서드이므로, 파서가 패닉 모드에 들어갈 때 동기화하기에 적절한 장소입니다. 이 메서드의 전체 본문은 파서가 오류 복구를 시작할 때 던져지는 예외를 잡기 위해 `try` 블록으로 감싸져 있습니다. 이를 통해 다음 문장이나 선언의 시작을 파싱하려 시도하는 상태로 돌아갈 수 있습니다.

실제 파싱은 `try` 블록 내부에서 발생합니다. 먼저, 선행하는 `var` 키워드를 찾아 변수 선언인지 확인합니다. 만약 아니라면, `print` 및 표현식 문장을 파싱하는 기존의 `statement()` 메서드로 넘어갑니다.

`statement()`가 다른 문장과 일치하지 않으면 표현식 문장을 파싱하려고 시도했던 것을 기억하시나요? 그리고 `expression()`은 현재 토큰에서 표현식을 파싱할 수 없으면 문법 오류를 보고했던 것은요? 그 호출 체인은 유효한 선언이나 문장이 파싱되지 않으면 오류를 보고하도록 보장합니다.

파서가 `var` 토큰과 일치하면, 다음으로 분기합니다.

lox/Parser.java
add after printStatement()
  private Stmt varDeclaration() {
    Token name = consume(IDENTIFIER, "Expect variable name.");

    Expr initializer = null;
    if (match(EQUAL)) {
      initializer = expression();
    }

    consume(SEMICOLON, "Expect ';' after variable declaration.");
    return new Stmt.Var(name, initializer);
  }
lox/Parser.java, add after printStatement()

항상 그렇듯이, 재귀 하강(recursive descent) 코드는 문법 규칙을 따릅니다. 파서는 이미 `var` 토큰을 매칭했으므로, 다음으로 변수 이름에 대한 식별자 토큰을 요구하고 소비합니다.

그런 다음 `=` 토큰을 발견하면, 초기화 표현식이 있다는 것을 알고 파싱합니다. 그렇지 않으면 초기화 변수를 `null`로 둡니다. 마지막으로, 문장 끝에 필요한 세미콜론을 소비합니다. 이 모든 것이 Stmt.Var 구문 트리 노드로 감싸지고 작업이 완료됩니다.

변수 표현식을 파싱하는 것은 훨씬 쉽습니다. `primary()`에서 식별자 토큰을 찾습니다.

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

    if (match(IDENTIFIER)) {
      return new Expr.Variable(previous());
    }

    if (match(LEFT_PAREN)) {
lox/Parser.java, in primary()

이것으로 변수를 선언하고 사용하는 작동하는 프런트엔드가 마련되었습니다. 남은 것은 이를 인터프리터에 주입하는 것입니다. 그 전에, 변수가 메모리 어디에 저장되는지에 대해 이야기해야 합니다.

8 . 3환경

변수를 값에 연결하는 바인딩은 어딘가에 저장되어야 합니다. Lisp 사람들이 괄호를 발명한 이래로, 이 데이터 구조는 **환경(environment)**이라고 불려왔습니다.

두 개의 바인딩을 포함하는 환경.

변수 이름이 키이고 변수의 값이 해당 값인 맵(map)처럼 생각할 수 있습니다. 실제로 Java에서는 그렇게 구현할 것입니다. 그 맵과 그것을 관리하는 코드를 Interpreter 안에 바로 넣을 수도 있지만, 깔끔하게 구분되는 개념을 형성하므로 별도의 클래스로 분리할 것입니다.

새 파일을 만들고 다음을 추가하세요.

lox/Environment.java
create new file
package com.craftinginterpreters.lox;

import java.util.HashMap;
import java.util.Map;

class Environment {
  private final Map<String, Object> values = new HashMap<>();
}
lox/Environment.java, create new file

바인딩을 저장하기 위한 Java Map이 있습니다. 키로는 토큰이 아닌 일반 문자열을 사용합니다. 토큰은 소스 텍스트의 특정 위치에 있는 코드 단위를 나타내지만, 변수를 찾을 때에는 이름이 같은 모든 식별자 토큰이 동일한 변수를 참조해야 합니다(지금은 스코프를 무시합니다). 원시 문자열을 사용하면 이 모든 토큰이 동일한 맵 키를 참조하도록 보장합니다.

우리가 지원해야 할 두 가지 연산이 있습니다. 첫째, 변수 정의는 새로운 이름을 값에 바인딩합니다.

lox/Environment.java
in class Environment
  void define(String name, Object value) {
    values.put(name, value);
  }
lox/Environment.java, in class Environment

대단히 복잡한 작업은 아니지만, 한 가지 흥미로운 의미론적 선택을 했습니다. 맵에 키를 추가할 때, 이미 존재하는지 확인하지 않습니다. 이는 다음 프로그램이 작동한다는 의미입니다.

var a = "before";
print a; // "before".
var a = "after";
print a; // "after".

변수 문장은 *새로운* 변수를 정의할 뿐만 아니라, 기존 변수를 *재*정의하는 데도 사용될 수 있습니다. 우리는 대신 이를 오류로 처리하는 것을 선택할 수도 있었습니다. 사용자가 기존 변수를 재정의하려 의도하지 않았을 수도 있습니다. (만약 의도했다면, 아마 `var` 대신 할당을 사용했을 것입니다.) 재정의를 오류로 만들면 해당 버그를 찾는 데 도움이 될 것입니다.

하지만 그렇게 하면 REPL과 제대로 작동하지 않습니다. REPL 세션 중간에 이미 정의한 변수가 무엇인지 정신적으로 추적할 필요가 없다는 것은 편리합니다. REPL에서는 재정의를 허용하고 스크립트에서는 허용하지 않을 수도 있지만, 그렇게 되면 사용자는 두 가지 규칙 세트를 배워야 하고, 한 형식에서 다른 형식으로 복사 붙여넣기한 코드가 작동하지 않을 수도 있습니다.

그래서 두 모드를 일관되게 유지하기 위해, 적어도 전역 변수에 대해서는 이를 허용할 것입니다. 변수가 일단 존재하면, 이를 조회할 방법이 필요합니다.

class Environment {
  private final Map<String, Object> values = new HashMap<>();
lox/Environment.java
in class Environment

  Object get(Token name) {
    if (values.containsKey(name.lexeme)) {
      return values.get(name.lexeme);
    }

    throw new RuntimeError(name,
        "Undefined variable '" + name.lexeme + "'.");
  }

  void define(String name, Object value) {
lox/Environment.java, in class Environment

이것은 의미론적으로 약간 더 흥미롭습니다. 변수를 찾으면 단순히 바인딩된 값을 반환합니다. 하지만 찾지 못하면 어떨까요? 다시 한번 선택지가 있습니다.

Lox는 꽤 유연하지만, 마지막 옵션은 저에게는 *너무* 관대합니다. 이를 문법 오류(컴파일 타임 오류)로 만드는 것이 현명한 선택처럼 보입니다. 정의되지 않은 변수를 사용하는 것은 버그이며, 실수를 빨리 감지할수록 좋습니다.

문제는 변수를 *사용하는* 것과 변수를 *참조하는* 것이 다르다는 것입니다. 코드 덩어리가 함수 안에 래핑되어 있다면, 그 코드 덩어리 안에서 변수를 즉시 평가하지 않고도 참조할 수 있습니다. 변수가 선언되기 전에 *언급하는* 것을 정적 오류(static error)로 만들면 재귀 함수를 정의하기가 훨씬 어려워집니다.

함수 본문을 검사하기 전에 함수 자신의 이름을 선언함으로써 단일 재귀(자기 자신을 호출하는 함수)를 수용할 수 있습니다. 그러나 이는 서로를 호출하는 상호 재귀 프로시저에는 도움이 되지 않습니다. 다음을 고려해 봅시다.

fun isOdd(n) {
  if (n == 0) return false;
  return isEven(n - 1);
}

fun isEven(n) {
  if (n == 0) return true;
  return isOdd(n - 1);
}

`isOdd()`의 본문을 보고 있는 시점에는 `isEven()` 함수가 정의되어 있지 않습니다. 만약 두 함수의 순서를 바꾼다면, `isEven()`의 본문을 보고 있을 때 `isOdd()`가 정의되어 있지 않을 것입니다.

*정적* 오류로 만들면 재귀 선언이 너무 어려워지므로, 오류를 런타임으로 미룰 것입니다. 정의되기 전에 변수를 참조하는 것은 괜찮지만, 그 참조를 *평가하지 않는* 한입니다. 이렇게 하면 짝수 및 홀수 프로그램은 작동하지만, 다음 코드에서는 런타임 오류가 발생할 것입니다.

print a;
var a = "too late!";

표현식 평가 코드의 타입 오류와 마찬가지로, 예외를 던져 런타임 오류를 보고합니다. 예외에는 변수의 토큰이 포함되어 사용자가 코드 어디에서 실수를 했는지 알려줄 수 있습니다.

8 . 3 . 1전역 변수 해석하기

Interpreter 클래스는 새로운 Environment 클래스의 인스턴스를 가집니다.

class Interpreter implements Expr.Visitor<Object>,
                             Stmt.Visitor<Void> {
lox/Interpreter.java
in class Interpreter
  private Environment environment = new Environment();

  void interpret(List<Stmt> statements) {
lox/Interpreter.java, in class Interpreter

변수들이 인터프리터가 실행되는 동안 메모리에 유지되도록 Interpreter 클래스 내부에 직접 필드로 저장합니다.

두 개의 새로운 구문 트리가 있으므로, 두 개의 새로운 방문(visit) 메서드가 필요합니다. 첫 번째는 선언 문장을 위한 것입니다.

lox/Interpreter.java
add after visitPrintStmt()
  @Override
  public Void visitVarStmt(Stmt.Var stmt) {
    Object value = null;
    if (stmt.initializer != null) {
      value = evaluate(stmt.initializer);
    }

    environment.define(stmt.name.lexeme, value);
    return null;
  }
lox/Interpreter.java, add after visitPrintStmt()

변수에 초기화 표현식이 있으면, 그것을 평가합니다. 없다면, 또 다른 선택을 해야 합니다. 초기화 표현식을 *필수적으로* 요구함으로써 파서에서 이를 문법 오류로 만들 수도 있었습니다. 하지만 대부분의 언어는 그렇게 하지 않으므로, Lox에서 그렇게 하는 것은 약간 가혹하게 느껴집니다.

런타임 오류로 만들 수도 있습니다. 초기화되지 않은 변수를 정의하도록 허용하되, 할당하기 전에 접근하면 런타임 오류가 발생하게 하는 것입니다. 나쁜 아이디어는 아니지만, 대부분의 동적 타입 언어는 그렇게 하지 않습니다. 대신, 우리는 단순하게 Lox가 명시적으로 초기화되지 않은 변수를 `nil`로 설정한다고 가정할 것입니다.

var a;
print a; // "nil".

따라서 초기화 표현식이 없으면, Lox의 `nil` 값을 Java로 표현한 `null`로 값을 설정합니다. 그런 다음 환경에 변수를 그 값에 바인딩하도록 지시합니다.

다음으로, 변수 표현식을 평가합니다.

lox/Interpreter.java
add after visitUnaryExpr()
  @Override
  public Object visitVariableExpr(Expr.Variable expr) {
    return environment.get(expr.name);
  }
lox/Interpreter.java, add after visitUnaryExpr()

이는 단순히 변수가 정의되었는지 확인하는 힘든 작업을 환경에 위임합니다. 이로써 기본적인 변수가 작동하게 되었습니다. 다음 코드를 실행해 보세요.

var a = 1;
var b = 2;
print a + b;

아직 *코드*를 재사용할 수는 없지만, *데이터*를 재사용하는 프로그램을 구축하기 시작할 수 있습니다.

8 . 4할당

변수는 있지만 재할당, 즉 **뮤테이션(mutation)**을 허용하지 않는 언어를 만들 수도 있습니다. Haskell이 한 예시입니다. SML은 오직 변경 가능한(mutable) 참조와 배열만을 지원하며, 변수는 재할당될 수 없습니다. Rust는 할당을 가능하게 하기 위해 `mut` 수정자를 요구함으로써 뮤테이션을 피하도록 유도합니다.

변수를 변경하는 것은 부수 효과이며, 이름이 암시하듯이 일부 언어 사용자들은 부수 효과를 지저분하거나 우아하지 않다고 생각합니다. 코드는 신성한 창조 행위처럼 값(결정처럼 맑고 변치 않는 값)을 생성하는 순수한 수학이어야 한다고 말합니다. 데이터를 한 번에 하나의 명령형 명령으로 모양을 잡는 더러운 자동 기계가 아니라 말이죠.

Lox는 그렇게 엄격하지 않습니다. Lox는 명령형 언어이며, 뮤테이션은 당연히 따라옵니다. 할당 지원을 추가하는 데 많은 작업이 필요하지 않습니다. 전역 변수는 이미 재정의를 지원하므로, 대부분의 메커니즘은 이미 갖춰져 있습니다. 주로 명시적인 할당 표기법이 없는 상태입니다.

8 . 4 . 1할당 문법

그 작은 `=` 문법은 보기보다 복잡합니다. 대부분의 C 기반 언어처럼, 할당은 문장이 아니라 표현식입니다. C에서처럼, 할당은 가장 낮은 우선순위의 표현식 형태입니다. 즉, 규칙이 `expression`과 `equality` (그 다음으로 낮은 우선순위 표현식) 사이에 위치합니다.

expressionassignment ;
assignmentIDENTIFIER "=" assignment
               | equality ;

이는 `assignment`가 식별자 뒤에 `=`와 값에 대한 표현식이 오는 형태이거나, `equality` (따라서 다른 모든) 표현식임을 말합니다. 나중에 객체에 속성 설정자(property setters)를 추가할 때 `assignment`는 더욱 복잡해질 것입니다. 예를 들어 다음과 같습니다.

instance.field = "value";

쉬운 부분은 새로운 구문 트리 노드를 추가하는 것입니다.

    defineAst(outputDir, "Expr", Arrays.asList(
tool/GenerateAst.java
in main()
      "Assign   : Token name, Expr value",
      "Binary   : Expr left, Token operator, Expr right",
tool/GenerateAst.java, in main()

이 노드에는 할당 대상 변수를 위한 토큰과 새로운 값을 위한 표현식이 포함됩니다. AstGenerator를 실행하여 새로운 Expr.Assign 클래스를 얻은 후, 파서의 기존 `expression()` 메서드 본문을 업데이트된 규칙에 맞게 변경하십시오.

  private Expr expression() {
lox/Parser.java
in expression()
replace 1 line
    return assignment();
  }
lox/Parser.java, in expression(), replace 1 line

여기가 까다로워지는 부분입니다. 단일 토큰 선행 재귀 하강 파서는 좌변을 처리하고 `=`를 만나기 *전*까지는 할당을 파싱하고 있다는 것을 충분히 멀리 볼 수 없습니다. 왜 그래야 하는지 궁금할 수도 있습니다. 결국, 우리는 좌변 피연산자를 파싱을 마칠 때까지 `+` 표현식을 파싱하고 있다는 것을 알지 못합니다.

차이점은 할당의 좌변은 값으로 평가되는 표현식이 아니라는 것입니다. 이는 할당할 수 있는 "대상"으로 평가되는 일종의 의사(pseudo) 표현식입니다. 다음을 고려해 보세요.

var a = "before";
a = "value";

두 번째 줄에서 우리는 `a`를 *평가하지* 않습니다(그렇게 하면 "before"라는 문자열을 반환할 것입니다). 우리는 `a`가 어떤 변수를 참조하는지 파악하여 우변 표현식의 값을 어디에 저장할지 압니다. 이 두 구성에 대한 고전적인 용어는 **l-값(l-value)**과 **r-값(r-value)**입니다. 지금까지 우리가 보았던 값을 생성하는 모든 표현식은 r-값입니다. l-값은 할당할 수 있는 저장 위치로 "평가"됩니다.

우리는 구문 트리가 l-값이 일반 표현식처럼 평가되지 않는다는 것을 반영하기를 원합니다. 그래서 Expr.Assign 노드의 좌변에 Expr 대신 *Token*이 있는 것입니다. 문제는 파서가 `=`를 만나기 전까지는 l-값을 파싱하고 있다는 것을 알지 못한다는 것입니다. 복잡한 l-값에서는 많은 토큰 뒤에 `=`가 나타날 수 있습니다.

makeList().head.next = node;

우리는 단일 토큰 선행(lookahead)만 가지고 있는데, 어떻게 해야 할까요? 우리는 작은 트릭을 사용하며, 그 모습은 다음과 같습니다.

lox/Parser.java
add after expressionStatement()
  private Expr assignment() {
    Expr expr = equality();

    if (match(EQUAL)) {
      Token equals = previous();
      Expr value = assignment();

      if (expr instanceof Expr.Variable) {
        Token name = ((Expr.Variable)expr).name;
        return new Expr.Assign(name, value);
      }

      error(equals, "Invalid assignment target."); 
    }

    return expr;
  }
lox/Parser.java, add after expressionStatement()

할당 표현식을 파싱하는 대부분의 코드는 `+`와 같은 다른 이항 연산자의 코드와 비슷하게 생겼습니다. 우리는 더 높은 우선순위의 어떤 표현식도 될 수 있는 좌변을 파싱합니다. 만약 `=`를 발견하면 우변을 파싱하고, 이 모든 것을 할당 표현식 트리 노드로 감쌉니다.

이항 연산자와의 한 가지 작은 차이점은, 동일한 연산자의 시퀀스를 구성하기 위해 루프를 사용하지 않는다는 것입니다. 할당은 우측 결합(right-associative)이므로, 대신 `assignment()`를 재귀적으로 호출하여 우변을 파싱합니다.

핵심 트릭은 할당 표현식 노드를 생성하기 직전에, 좌변 표현식을 보고 어떤 종류의 할당 대상인지 파악하는 것입니다. 우리는 r-값 표현식 노드를 l-값 표현으로 변환합니다.

이 변환이 작동하는 이유는 모든 유효한 할당 대상이 일반 표현식으로서도 유효한 문법이라는 것이 밝혀졌기 때문입니다. 다음처럼 복잡한 필드 할당을 고려해 보세요.

newPoint(x + 2, 0).y = 3;

그 할당의 좌변은 유효한 표현식으로도 작동할 수 있습니다.

newPoint(x + 2, 0).y;

첫 번째 예제는 필드를 설정하고, 두 번째 예제는 필드를 가져옵니다.

이는 좌변을 마치 표현식인 *것처럼* 파싱한 다음, 나중에 구문 트리를 생성하여 이를 할당 대상으로 변환할 수 있음을 의미합니다. 만약 좌변 표현식이 유효한 할당 대상이 아니라면, 구문 오류와 함께 실패합니다. 이는 다음과 같은 코드에 대해 오류를 보고하도록 보장합니다.

a + b = c;

지금은 유효한 유일한 대상이 단순한 변수 표현식이지만, 나중에 필드를 추가할 것입니다. 이 트릭의 최종 결과는 무엇에 할당하는지 알고 있고 할당될 값을 위한 표현식 서브트리를 가진 할당 표현식 트리 노드입니다. 이 모든 것이 단일 토큰 선행(lookahead)과 백트래킹 없이 이루어집니다.

8 . 4 . 2할당 의미론

새로운 구문 트리 노드가 있으므로, 우리 인터프리터는 새로운 방문(visit) 메서드를 갖게 됩니다.

lox/Interpreter.java
add after visitVarStmt()
  @Override
  public Object visitAssignExpr(Expr.Assign expr) {
    Object value = evaluate(expr.value);
    environment.assign(expr.name, value);
    return value;
  }
lox/Interpreter.java, add after visitVarStmt()

명백한 이유로, 이는 변수 선언과 유사합니다. 우변을 평가하여 값을 얻은 다음, 이름이 지정된 변수에 저장합니다. Environment의 `define()` 대신 이 새로운 메서드를 호출합니다.

lox/Environment.java
add after get()
  void assign(Token name, Object value) {
    if (values.containsKey(name.lexeme)) {
      values.put(name.lexeme, value);
      return;
    }

    throw new RuntimeError(name,
        "Undefined variable '" + name.lexeme + "'.");
  }
lox/Environment.java, add after get()

할당과 정의의 주요 차이점은 할당이 새로운 변수를 생성하는 것을 허용하지 않는다는 것입니다. 우리 구현의 관점에서 보면, 환경의 변수 맵에 키가 이미 존재하지 않으면 런타임 오류가 발생한다는 의미입니다.

`visit()` 메서드가 하는 마지막 작업은 할당된 값을 반환하는 것입니다. 이는 할당이 다른 표현식 안에 중첩될 수 있는 표현식이기 때문입니다. 예를 들어 다음과 같습니다.

var a = 1;
print a = 2; // "2".

이제 우리 인터프리터는 변수를 생성하고, 읽고, 수정할 수 있습니다. 초기 BASIC과 거의 같은 수준입니다. 전역 변수는 간단하지만, 두 코드 덩어리가 실수로 서로의 상태를 덮어쓸 수 있는 상황에서 큰 프로그램을 작성하는 것은 즐겁지 않습니다. 우리는 지역 변수를 원하며, 이는 이제 스코프를 다룰 시간이라는 의미입니다.

8 . 5스코프

스코프(scope)는 이름이 특정 엔티티에 매핑되는 영역을 정의합니다. 여러 스코프를 통해 동일한 이름이 다른 컨텍스트에서 다른 대상을 참조할 수 있습니다. 우리 집에서 "밥(Bob)"은 보통 저를 지칭합니다. 하지만 당신의 동네에서는 다른 "밥"을 알고 있을 수도 있습니다. 같은 이름이지만, 어디서 말하느냐에 따라 다른 인물을 지칭하는 것이죠.

렉시컬 스코프(Lexical scope) (또는 덜 흔하게 들리는 정적 스코프(static scope))는 프로그램 코드 자체가 스코프가 시작되고 끝나는 지점을 보여주는 특정 스코핑 스타일입니다. 대부분의 현대 언어에서처럼 Lox에서도 변수는 렉시컬 스코프를 따릅니다. 어떤 변수를 사용하는 표현식을 볼 때, 코드를 정적으로 읽기만 해도 어떤 변수 선언을 참조하는지 알아낼 수 있습니다.

예를 들어:

{
  var a = "first";
  print a; // "first".
}

{
  var a = "second";
  print a; // "second".
}

여기에는 각각 `a` 변수가 선언된 두 개의 블록이 있습니다. 코드만 봐도 첫 번째 `print` 문장의 `a`는 첫 번째 `a`를, 두 번째 `print` 문장의 `a`는 두 번째 `a`를 참조한다는 것을 당신과 저는 알 수 있습니다.

각 'a'에 대한 환경.

이는 코드를 실행하기 전까지는 이름이 무엇을 참조하는지 알 수 없는 **동적 스코프(dynamic scope)**와 대조됩니다. Lox는 동적으로 스코프가 지정된 *변수*는 없지만, 객체의 메서드와 필드는 동적으로 스코프가 지정됩니다.

class Saxophone {
  play() {
    print "Careless Whisper";
  }
}

class GolfClub {
  play() {
    print "Fore!";
  }
}

fun playIt(thing) {
  thing.play();
}

`playIt()`이 `thing.play()`를 호출할 때, 우리가 "Careless Whisper"를 듣게 될지 "Fore!"를 듣게 될지 알 수 없습니다. 이는 함수에 Saxophone을 전달하느냐 GolfClub을 전달하느냐에 달려 있으며, 이는 런타임까지 알 수 없습니다.

스코프와 환경은 밀접한 관계에 있습니다. 전자는 이론적인 개념이고, 후자는 이를 구현하는 메커니즘입니다. 인터프리터가 코드를 처리하면서, 스코프에 영향을 미치는 구문 트리 노드는 환경을 변경할 것입니다. Lox와 같은 C 스타일 문법에서 스코프는 중괄호 블록에 의해 제어됩니다. (이것이 우리가 이를 **블록 스코프(block scope)**라고 부르는 이유입니다.)

{
  var a = "in block";
}
print a; // 오류! 더 이상 "a"가 없습니다.

블록의 시작은 새로운 지역 스코프를 도입하며, 실행이 닫는 `}`를 지나면 해당 스코프는 종료됩니다. 블록 내부에 선언된 모든 변수는 사라집니다.

8 . 5 . 1중첩과 쉐도잉

블록 스코프를 구현하는 첫 번째 시도는 다음과 같을 수 있습니다.

  1. 블록 내의 각 문장을 방문하면서, 선언된 변수들을 추적합니다.

  2. 마지막 문장이 실행된 후, 환경에게 그 변수들을 모두 삭제하도록 지시합니다.

이는 이전 예제에서는 작동할 것입니다. 하지만 기억하십시오, 지역 스코프의 한 가지 동기는 캡슐화입니다. 프로그램의 한 구석에 있는 코드 블록이 다른 블록과 간섭해서는 안 됩니다. 다음을 확인해 보세요.

// 얼마나 시끄럽게?
var volume = 11;

// 침묵.
volume = 0;

// 3x4x5 직육면체의 부피 계산.
{
  var volume = 3 * 4 * 5;
  print volume;
}

지역 `volume` 선언을 사용하여 직육면체의 부피를 계산하는 블록을 보세요. 블록이 종료된 후, 인터프리터는 *전역* `volume` 변수를 삭제할 것입니다. 이건 옳지 않습니다. 블록을 나갈 때 블록 내부에 선언된 변수는 제거해야 하지만, 블록 외부에 동일한 이름으로 선언된 변수가 있다면, *그것은 다른 변수입니다*. 그것은 건드려져서는 안 됩니다.

지역 변수가 외부 스코프에 있는 변수와 같은 이름을 가질 때, 그 지역 변수는 외부 변수를 **쉐도잉(shadows)**합니다. 블록 내부의 코드는 더 이상 외부 변수를 볼 수 없습니다. 내부 변수에 의해 드리워진 "그림자" 속에 숨겨져 있지만, 여전히 존재합니다.

새로운 블록 스코프에 진입할 때, 외부 스코프에 정의된 변수들을 보존하여 내부 블록을 나갈 때 여전히 존재하도록 해야 합니다. 이를 위해 각 블록에 대해 해당 스코프에서 정의된 변수만 포함하는 새로운 환경을 정의합니다. 블록을 나갈 때, 해당 환경을 버리고 이전 환경을 복원합니다.

또한 쉐도잉되지 *않는* 외부 변수도 처리해야 합니다.

var global = "outside";
{
  var local = "inside";
  print global + local;
}

여기서 `global`은 외부 전역 환경에 존재하고, `local`은 블록 환경 내부에 정의됩니다. 해당 `print` 문장에서는 이 두 변수 모두 스코프 안에 있습니다. 이 변수들을 찾기 위해 인터프리터는 현재 가장 안쪽 환경뿐만 아니라 모든 외부 환경도 검색해야 합니다.

우리는 환경을 함께 연결하여 이를 구현합니다. 각 환경은 바로 외부 스코프의 환경에 대한 참조를 가집니다. 변수를 찾을 때, 가장 안쪽부터 바깥쪽으로 그 체인을 따라가며 변수를 찾을 때까지 탐색합니다. 내부 스코프에서 시작하는 것이 지역 변수가 외부 변수를 쉐도잉하도록 만드는 방법입니다.

각 스코프에 대한 환경들이 함께 연결되어 있는 모습.

문법에 블록 구문을 추가하기 전에, Environment 클래스에 이러한 중첩 지원을 강화할 것입니다. 먼저, 각 환경이 자신을 둘러싼 환경에 대한 참조를 가지도록 합니다.

class Environment {
lox/Environment.java
in class Environment
  final Environment enclosing;
  private final Map<String, Object> values = new HashMap<>();
lox/Environment.java, in class Environment

이 필드는 초기화되어야 하므로, 두 개의 생성자를 추가합니다.

lox/Environment.java
in class Environment
  Environment() {
    enclosing = null;
  }

  Environment(Environment enclosing) {
    this.enclosing = enclosing;
  }
lox/Environment.java, in class Environment

인수 없는 생성자는 체인의 끝인 전역 스코프의 환경을 위한 것입니다. 다른 생성자는 주어진 외부 스코프 내부에 중첩된 새로운 지역 스코프를 생성합니다.

`define()` 메서드는 건드릴 필요가 없습니다. 새로운 변수는 항상 현재 가장 안쪽 스코프에서 선언됩니다. 하지만 변수 조회와 할당은 기존 변수와 작동하며, 변수를 찾기 위해 체인을 따라가야 합니다. 먼저, 조회입니다.

      return values.get(name.lexeme);
    }
lox/Environment.java
in get()

    if (enclosing != null) return enclosing.get(name);

    throw new RuntimeError(name,
        "Undefined variable '" + name.lexeme + "'.");
lox/Environment.java, in class Environment

변수가 현재 환경에서 발견되지 않으면, 단순히 외부 환경을 시도합니다. 이는 다시 재귀적으로 같은 작업을 수행하므로, 결국 전체 체인을 탐색하게 됩니다. 만약 외부 환경이 없는 환경에 도달했는데도 변수를 찾지 못하면, 이전과 같이 포기하고 오류를 보고합니다.

할당도 같은 방식으로 작동합니다.

      values.put(name.lexeme, value);
      return;
    }

lox/Environment.java
in assign()
    if (enclosing != null) {
      enclosing.assign(name, value);
      return;
    }

    throw new RuntimeError(name,
lox/Environment.java, in class Environment

다시 말해, 변수가 현재 환경에 없으면 재귀적으로 외부 환경을 확인합니다.

8 . 5 . 2블록 문법과 의미론

이제 환경이 중첩되므로, 언어에 블록을 추가할 준비가 되었습니다. 문법을 살펴보세요.

statementexprStmt
               | printStmt
               | block ;

block"{" declaration* "}" ;

블록은 중괄호로 둘러싸인 (비어 있을 수도 있는) 일련의 문장 또는 선언입니다. 블록 자체는 문장이므로 문장이 허용되는 모든 곳에 나타날 수 있습니다. 구문 트리 노드는 다음과 같습니다.

    defineAst(outputDir, "Stmt", Arrays.asList(
tool/GenerateAst.java
in main()
      "Block      : List<Stmt> statements",
      "Expression : Expr expression",
tool/GenerateAst.java, in main()

이는 블록 내부에 있는 문장 목록을 포함합니다. 파싱은 간단합니다. 다른 문장들처럼, 우리는 블록의 시작을 선행 토큰, 이 경우 `{`로 감지합니다. `statement()` 메서드에 다음을 추가합니다.

    if (match(PRINT)) return printStatement();
lox/Parser.java
in statement()
    if (match(LEFT_BRACE)) return new Stmt.Block(block());

    return expressionStatement();
lox/Parser.java, in statement()

실제 작업은 여기에서 이루어집니다.

lox/Parser.java
add after expressionStatement()
  private List<Stmt> block() {
    List<Stmt> statements = new ArrayList<>();

    while (!check(RIGHT_BRACE) && !isAtEnd()) {
      statements.add(declaration());
    }

    consume(RIGHT_BRACE, "Expect '}' after block.");
    return statements;
  }
lox/Parser.java, add after expressionStatement()

우리는 빈 목록을 생성한 다음, 닫는 `}`로 표시된 블록의 끝에 도달할 때까지 문장을 파싱하여 목록에 추가합니다. 루프에는 `isAtEnd()`에 대한 명시적인 확인도 포함되어 있다는 점에 유의하세요. 유효하지 않은 코드를 파싱할 때도 무한 루프에 빠지지 않도록 주의해야 합니다. 사용자가 닫는 `}`를 잊어버리면, 파서가 멈추지 않아야 합니다.

문법은 여기까지입니다. 의미론을 위해서는 Interpreter에 또 다른 방문(visit) 메서드를 추가합니다.

lox/Interpreter.java
add after execute()
  @Override
  public Void visitBlockStmt(Stmt.Block stmt) {
    executeBlock(stmt.statements, new Environment(environment));
    return null;
  }
lox/Interpreter.java, add after execute()

블록을 실행하기 위해, 블록 스코프를 위한 새로운 환경을 생성하고 이를 다른 메서드에 전달합니다.

lox/Interpreter.java
add after execute()
  void executeBlock(List<Stmt> statements,
                    Environment environment) {
    Environment previous = this.environment;
    try {
      this.environment = environment;

      for (Stmt statement : statements) {
        execute(statement);
      }
    } finally {
      this.environment = previous;
    }
  }
lox/Interpreter.java, add after execute()

이 새로운 메서드는 주어진 환경의 컨텍스트에서 문장 목록을 실행합니다. 지금까지 Interpreter의 `environment` 필드는 항상 동일한 환경, 즉 전역 환경을 가리켰습니다. 이제 이 필드는 *현재* 환경을 나타냅니다. 이는 실행될 코드를 포함하는 가장 안쪽 스코프에 해당하는 환경입니다.

주어진 스코프 내에서 코드를 실행하기 위해, 이 메서드는 인터프리터의 `environment` 필드를 업데이트하고, 모든 문장을 방문한 다음, 이전 값을 복원합니다. Java에서 항상 좋은 관행이듯이, `finally` 절을 사용하여 이전 환경을 복원합니다. 그렇게 하면 예외가 발생하더라도 복원이 됩니다.

놀랍게도, 지역 변수, 중첩, 쉐도잉을 완전히 지원하기 위해 필요한 모든 것은 이것뿐입니다. 직접 한번 시도해보세요.

var a = "global a";
var b = "global b";
var c = "global c";
{
  var a = "outer a";
  var b = "outer b";
  {
    var a = "inner a";
    print a;
    print b;
    print c;
  }
  print a;
  print b;
  print c;
}
print a;
print b;
print c;

이제 우리의 작은 인터프리터는 무언가를 기억할 수 있습니다. 우리는 완전한 기능을 갖춘 프로그래밍 언어와 점점 더 가까워지고 있습니다.

도전 과제

  1. REPL이 더 이상 단일 표현식을 입력하고 그 결과 값을 자동으로 출력하는 것을 지원하지 않습니다. 이는 불편합니다. REPL에 사용자가 문장과 표현식 모두를 입력할 수 있도록 지원을 추가하세요. 문장을 입력하면 실행하고, 표현식을 입력하면 평가하여 결과 값을 표시하세요.

  2. Lox가 변수 초기화에 대해 조금 더 명시적이기를 원할 수도 있습니다. 변수를 암묵적으로 `nil`로 초기화하는 대신, 다음과 같이 초기화되거나 할당되지 않은 변수에 접근할 때 런타임 오류가 발생하도록 만드세요.

    // 초기화 없음.
    var a;
    var b;
    
    a = "assigned";
    print a; // OK, 먼저 할당됨.
    
    print b; // 오류!
    
  3. 다음 프로그램은 무엇을 할까요?

    var a = 1;
    {
      var a = a + 2;
      print a;
    }
    

    무엇을 할 것이라고 *예상했나요*? 당신이 생각하는 대로 작동하나요? 익숙한 다른 언어의 유사한 코드는 무엇을 하나요? 사용자들은 이것이 무엇을 할 것이라고 예상할까요?

설계 노트: 암묵적 변수 선언

Lox는 새로운 변수를 선언하는 구문과 기존 변수에 할당하는 구문이 다릅니다. 일부 언어는 이들을 단지 할당 구문으로 통합합니다. 존재하지 않는 변수에 할당하면 자동으로 변수가 생성됩니다. 이를 **암묵적 변수 선언(implicit variable declaration)**이라고 하며, Python, Ruby, CoffeeScript 등에서 찾아볼 수 있습니다. JavaScript는 변수를 선언하는 명시적 구문이 있지만, 할당 시 새로운 변수를 생성할 수도 있습니다. Visual Basic은 암묵적 변수를 활성화하거나 비활성화하는 옵션을 가지고 있습니다.

동일한 구문이 변수를 할당하거나 생성할 수 있을 때, 각 언어는 사용자가 어떤 동작을 의도하는지 명확하지 않을 때 어떻게 처리할지 결정해야 합니다. 특히, 각 언어는 암묵적 선언이 쉐도잉(shadowing)과 어떻게 상호작용하는지, 그리고 암묵적으로 선언된 변수가 어떤 스코프에 들어가는지를 선택해야 합니다.

  • Python에서는 할당이 항상 현재 함수의 스코프에 변수를 생성합니다. 함수 외부에 동일한 이름의 변수가 선언되어 있어도 마찬가지입니다.

  • Ruby는 지역 변수와 전역 변수에 대한 다른 명명 규칙을 가짐으로써 일부 모호함을 피합니다. 그러나 Ruby의 블록(C의 "블록"이라기보다는 클로저에 가깝습니다)은 자체 스코프를 가지므로 여전히 문제가 있습니다. Ruby에서 할당은 현재 블록 외부에 동일한 이름의 기존 변수가 있으면 그 변수에 할당합니다. 그렇지 않으면 현재 블록의 스코프에 새로운 변수를 생성합니다.

  • 여러 면에서 Ruby를 따르는 CoffeeScript도 비슷합니다. CoffeeScript는 할당이 항상 외부 스코프에 변수가 있다면 최외곽 전역 스코프까지 그 변수에 할당한다고 명시하여 쉐도잉을 명시적으로 금지합니다. 그렇지 않으면 현재 함수 스코프에 변수를 생성합니다.

  • JavaScript에서는 할당이 어떤 외부 스코프에서 기존 변수를 찾으면 그 변수를 수정합니다. 찾지 못하면 *전역* 스코프에 새로운 변수를 암묵적으로 생성합니다.

암묵적 선언의 주요 장점은 단순성입니다. 문법이 적고, 배워야 할 "선언" 개념이 없습니다. 사용자는 그냥 무언가를 할당하기 시작하면 언어가 알아서 처리합니다.

C와 같은 오래된 정적 타입 언어는 명시적 선언을 통해 각 변수의 타입과 할당할 저장 공간의 크기를 컴파일러에 알려줄 수 있어 이점을 얻습니다. 동적 타입, 가비지 컬렉션 언어에서는 그것이 정말로 필요하지 않으므로, 선언을 암묵적으로 만들 수 있습니다. 약간 더 "스크립트적"이고, "내 말뜻을 알지"와 같은 느낌을 줍니다.

하지만 그것이 좋은 아이디어일까요? 암묵적 선언에는 몇 가지 문제가 있습니다.

  • 사용자가 기존 변수에 할당하려고 했으나 오타를 냈을 수 있습니다. 인터프리터는 이를 알지 못하므로, 새로운 변수를 조용히 생성하고 사용자가 할당하려던 변수는 여전히 이전 값을 가집니다. 이는 오타가 *전역* 변수를 생성하여 다른 코드와 간섭할 수 있는 JavaScript에서 특히 심각합니다.

  • JS, Ruby, CoffeeScript는 동일한 이름의 기존 변수(심지어 외부 스코프에 있는 변수라도)의 존재 여부를 사용하여 할당이 새로운 변수를 생성하는지 아니면 기존 변수에 할당하는지를 결정합니다. 이는 주변 스코프에 새로운 변수를 추가하는 것이 기존 코드의 의미를 변경할 수 있다는 것을 의미합니다. 한때 지역 변수였던 것이 조용히 그 새로운 외부 변수에 대한 할당으로 바뀔 수 있습니다.

  • Python에서는 현재 함수 내부에 새로운 변수를 생성하는 대신 현재 함수 외부의 어떤 변수에 할당하고 *싶을* 수도 있지만, 그렇게 할 수 없습니다.

시간이 지나면서, 제가 아는 암묵적 변수 선언을 가진 언어들은 이러한 문제들을 해결하기 위해 더 많은 기능과 복잡성을 추가하게 되었습니다.

  • JavaScript의 전역 변수 암묵적 선언은 오늘날 보편적으로 실수로 간주됩니다. "엄격 모드(Strict mode)"는 이를 비활성화하고 컴파일 오류로 만듭니다.

  • Python은 함수 내에서 전역 변수에 명시적으로 할당할 수 있도록 `global` 문장을 추가했습니다. 나중에 함수형 프로그래밍과 중첩 함수가 더 인기를 얻으면서, 외부 함수에 있는 변수에 할당하기 위한 유사한 `nonlocal` 문장도 추가했습니다.

  • Ruby는 블록 문법을 확장하여 외부 스코프에 동일한 이름이 존재하더라도 특정 변수를 블록에 명시적으로 지역 변수로 선언할 수 있도록 했습니다.

이러한 점들을 고려할 때, 단순성이라는 주장은 대부분 설득력을 잃었다고 생각합니다. 암묵적 선언이 올바른 *기본값*이라는 주장도 있지만, 저는 개인적으로 그것이 덜 설득력 있다고 생각합니다.

제 생각에 암묵적 선언은 대부분의 스크립트 언어가 명령형적이고 코드가 비교적 평탄했던 과거에는 의미가 있었습니다. 프로그래머들이 깊은 중첩, 함수형 프로그래밍, 클로저에 더 익숙해지면서, 외부 스코프의 변수에 접근하고 싶어 하는 경우가 훨씬 더 흔해졌습니다. 이는 사용자들이 할당이 새로운 변수를 생성하려는 것인지 아니면 주변 변수를 재사용하려는 것인지 불분명한 까다로운 경우를 만날 가능성을 높입니다.

그래서 저는 변수를 명시적으로 선언하는 것을 선호하며, 이것이 Lox가 이를 요구하는 이유입니다.