12

클래스

어떤 것의 본질에 대한 철저한 지식을 습득하지 못했다면, 그것을 사랑하거나 미워할 자격이 없다. 위대한 사랑은 사랑하는 대상에 대한 깊은 지식에서 비롯되며, 아는 것이 적으면 사랑도 적거나 아예 없을 것이다.

레오나르도 다 빈치

벌써 11개 챕터를 지나왔고, 여러분의 컴퓨터에서 실행되는 인터프리터는 거의 완전한 스크립팅 언어가 되었습니다. 리스트나 맵 같은 몇 가지 내장 자료구조와 파일 I/O, 사용자 입력 등을 위한 핵심 라이브러리가 필요하겠지만, 언어 자체는 충분합니다. 우리는 BASIC, Tcl, Scheme(매크로 제외), 그리고 초기 버전의 Python 및 Lua와 비슷한 맥락의 작은 절차형 언어를 만들었습니다.

만약 80년대였다면 여기서 멈췄을 겁니다. 하지만 오늘날에는 많은 인기 언어가 '객체 지향 프로그래밍'을 지원합니다. 이를 Lox에 추가하면 사용자들이 더 큰 프로그램을 작성할 때 익숙한 도구 세트를 제공할 수 있을 것입니다. 개인적으로 OOP를 선호하지 않더라도, 이 챕터와 다음 챕터는 다른 사람들이 객체 시스템을 어떻게 설계하고 구축하는지 이해하는 데 도움이 될 것입니다.

12 . 1OOP와 클래스

객체 지향 프로그래밍에는 크게 세 가지 접근 방식이 있습니다: 클래스, 프로토타입, 그리고 멀티메서드입니다. 클래스가 가장 먼저 등장했으며 가장 보편적인 스타일입니다. JavaScript(및 그보다는 덜하지만 Lua)의 부상과 함께 프로토타입도 과거보다 널리 알려지게 되었습니다. 이에 대해서는 나중에 더 이야기하겠습니다. Lox에서는, 흠, 고전적인 접근 방식을 따르겠습니다.

저와 함께 이미 천 줄 가량의 Java 코드를 작성했으니, 객체 지향에 대한 자세한 소개는 필요 없으리라 가정합니다. 핵심 목표는 데이터를 해당 데이터에 작용하는 코드와 함께 묶는 것입니다. 사용자들은 다음을 수행하는 클래스를 선언함으로써 이를 달성합니다.

  1. 클래스의 새로운 인스턴스를 생성하고 초기화하는 생성자를 노출합니다.

  2. 인스턴스에 필드를 저장하고 접근하는 방법을 제공합니다.

  3. 클래스의 모든 인스턴스가 공유하며 각 인스턴스의 상태에서 작동하는 메서드 집합을 정의합니다.

이것이 최소한의 요구 사항입니다. Simula부터 시작된 대부분의 객체 지향 언어는 클래스 간의 동작 재사용을 위해 상속(inheritance)도 지원합니다. 이는 다음 챕터에서 추가할 것입니다. 상속을 제외하더라도, 여전히 다뤄야 할 부분이 많습니다. 이 챕터는 내용이 많고 위의 모든 요소가 갖춰져야 비로소 모든 것이 온전히 동작하므로, 체력을 비축해 두십시오.

12 . 2클래스 선언

늘 그렇듯이, 문법부터 시작하겠습니다. `class` 문은 새로운 이름을 도입하므로, `declaration` 문법 규칙에 속합니다.

declarationclassDecl
               | funDecl
               | varDecl
               | statement ;

classDecl"class" IDENTIFIER "{" function* "}" ;

새로운 `classDecl` 규칙은 이전에 정의했던 `function` 규칙에 의존합니다. 기억을 되살려보면:

functionIDENTIFIER "(" parameters? ")" block ;
parametersIDENTIFIER ( "," IDENTIFIER )* ;

쉽게 말해, 클래스 선언은 `class` 키워드 뒤에 클래스 이름이 오고, 이어서 중괄호로 묶인 본문이 옵니다. 본문 안에는 메서드 선언 목록이 있습니다. 함수 선언과 달리, 메서드에는 선행하는 `fun` 키워드가 없습니다. 각 메서드는 이름, 매개변수 목록, 그리고 본문으로 구성됩니다. 다음은 예시입니다.

class Breakfast {
  cook() {
    print "Eggs a-fryin'!";
  }

  serve(who) {
    print "Enjoy your breakfast, " + who + ".";
  }
}

대부분의 동적 타입 언어처럼, 필드는 클래스 선언에 명시적으로 나열되지 않습니다. 인스턴스는 느슨한 데이터 묶음이며, 일반적인 명령형 코드를 사용하여 필요에 따라 자유롭게 필드를 추가할 수 있습니다.

AST 생성기에서는 `classDecl` 문법 규칙이 자체적인 문장 노드를 얻습니다.

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

이 노드는 클래스 이름과 본문 안에 있는 메서드들을 저장합니다. 메서드는 함수 선언 AST 노드에 사용하는 기존 Stmt.Function 클래스로 표현됩니다. 이는 메서드에 필요한 모든 상태 정보, 즉 이름, 매개변수 목록, 본문을 제공합니다.

`class` 키워드로 시작하여, 클래스는 이름이 있는 선언이 허용되는 모든 곳에 나타날 수 있습니다.

    try {
lox/Parser.java
in declaration()
      if (match(CLASS)) return classDeclaration();
      if (match(FUN)) return function("function");
lox/Parser.java, in declaration()

이는 다음을 호출합니다:

lox/Parser.java
add after declaration()
  private Stmt classDeclaration() {
    Token name = consume(IDENTIFIER, "Expect class name.");
    consume(LEFT_BRACE, "Expect '{' before class body.");

    List<Stmt.Function> methods = new ArrayList<>();
    while (!check(RIGHT_BRACE) && !isAtEnd()) {
      methods.add(function("method"));
    }

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

    return new Stmt.Class(name, methods);
  }
lox/Parser.java, add after declaration()

이 메서드는 다른 파싱 메서드들보다 내용이 많지만, 대략적으로 문법을 따릅니다. 이미 `class` 키워드를 소비했으므로, 다음으로 예상되는 클래스 이름을 찾고 이어서 여는 중괄호를 찾습니다. 본문 안에서는 닫는 중괄호를 만날 때까지 메서드 선언을 계속 파싱합니다. 각 메서드 선언은 함수가 소개된 챕터에서 정의했던 `function()` 호출을 통해 파싱됩니다.

파서의 다른 개방형 루프에서와 마찬가지로, 파일 끝에 도달하는지도 확인합니다. 올바른 코드에서는 클래스가 끝에 닫는 중괄호를 가져야 하므로 이런 일은 발생하지 않겠지만, 사용자가 구문 오류를 범하여 클래스 본문을 올바르게 닫는 것을 잊어버린 경우 파서가 무한 루프에 빠지는 것을 방지합니다.

클래스 이름과 메서드 목록을 Stmt.Class 노드로 묶으면 작업이 완료됩니다. 이전에는 바로 인터프리터로 넘어갔겠지만, 이제는 먼저 노드를 리졸버(resolver)를 통해 연결해야 합니다.

lox/Resolver.java
add after visitBlockStmt()
  @Override
  public Void visitClassStmt(Stmt.Class stmt) {
    declare(stmt.name);
    define(stmt.name);
    return null;
  }
lox/Resolver.java, add after visitBlockStmt()

아직 메서드 자체를 리졸브하는 것에 대해서는 신경 쓰지 않을 것이므로, 지금은 클래스 이름을 사용하여 클래스를 선언하기만 하면 됩니다. 클래스를 지역 변수로 선언하는 것은 일반적이지 않지만, Lox는 이를 허용하므로 올바르게 처리해야 합니다.

이제 클래스 선언을 인터프리트합니다.

lox/Interpreter.java
add after visitBlockStmt()
  @Override
  public Void visitClassStmt(Stmt.Class stmt) {
    environment.define(stmt.name.lexeme, null);
    LoxClass klass = new LoxClass(stmt.name.lexeme);
    environment.assign(stmt.name, klass);
    return null;
  }
lox/Interpreter.java, add after visitBlockStmt()

이것은 함수 선언을 실행하는 방식과 유사하게 보입니다. 현재 환경에 클래스 이름을 선언합니다. 그런 다음 클래스 구문 노드를 클래스의 런타임 표현인 LoxClass로 바꿉니다. 다시 돌아와서 이전에 선언한 변수에 클래스 객체를 저장합니다. 이 두 단계 변수 바인딩 과정은 클래스 자체 메서드 내에서 클래스에 대한 참조를 허용합니다.

이 챕터 내내 이를 개선해 나갈 것이지만, LoxClass의 첫 초안은 다음과 같습니다:

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

import java.util.List;
import java.util.Map;

class LoxClass {
  final String name;

  LoxClass(String name) {
    this.name = name;
  }

  @Override
  public String toString() {
    return name;
  }
}
lox/LoxClass.java, create new file

문자 그대로 이름 주위의 래퍼입니다. 아직 메서드조차 저장하지 않습니다. 아주 유용하지는 않지만, `toString()` 메서드가 있어서 간단한 스크립트를 작성하고 클래스 객체가 실제로 파싱되고 실행되는지 테스트할 수 있습니다.

class DevonshireCream {
  serveOn() {
    return "Scones";
  }
}

print DevonshireCream; // "DevonshireCream"을 출력합니다.

12 . 3인스턴스 생성

클래스는 있지만, 아직 아무것도 하지 않습니다. Lox에는 클래스 자체에서 직접 호출할 수 있는 '정적' 메서드가 없으므로, 실제 인스턴스가 없으면 클래스는 무용지물입니다. 따라서 인스턴스가 다음 단계입니다.

일부 구문과 의미는 OOP 언어 전반에 걸쳐 상당히 표준적이지만, 새로운 인스턴스를 생성하는 방식은 그렇지 않습니다. Smalltalk를 따르는 Ruby는 클래스 객체 자체의 메서드를 호출하여 인스턴스를 생성하는데, 이는 재귀적으로 우아한 접근 방식입니다. C++나 Java와 같은 일부 언어는 새로운 객체를 생성하는 데 전용 `new` 키워드를 가지고 있습니다. Python은 클래스 자체를 함수처럼 '호출'합니다. (JavaScript는 늘 그렇듯이, 이 두 가지를 모두 하는 듯합니다.)

Lox에서는 최소한의 접근 방식을 취했습니다. 이미 클래스 객체와 함수 호출이 있으므로, 클래스 객체에 대한 호출 표현식을 사용하여 새로운 인스턴스를 생성할 것입니다. 마치 클래스가 자기 자신의 인스턴스를 생성하는 팩토리 함수처럼 작동하는 것이죠. 이는 제게 우아하게 느껴지며, `new`와 같은 구문을 도입할 필요도 없게 해줍니다. 따라서 프런트엔드를 건너뛰고 바로 런타임으로 들어갈 수 있습니다.

지금 이 코드를 시도하면:

class Bagel {}
Bagel();

런타임 오류가 발생합니다. `visitCallExpr()`는 호출된 객체가 `LoxCallable`을 구현하는지 확인하는데, LoxClass는 아직 구현하지 않았으므로 오류를 보고합니다. 아직 구현하지 않았다는 뜻입니다.

import java.util.Map;

lox/LoxClass.java
replace 1 line
class LoxClass implements LoxCallable {
  final String name;
lox/LoxClass.java, replace 1 line

이 인터페이스를 구현하려면 두 개의 메서드가 필요합니다.

lox/LoxClass.java
add after toString()
  @Override
  public Object call(Interpreter interpreter,
                     List<Object> arguments) {
    LoxInstance instance = new LoxInstance(this);
    return instance;
  }

  @Override
  public int arity() {
    return 0;
  }
lox/LoxClass.java, add after toString()

흥미로운 부분은 `call()`입니다. 클래스를 '호출'하면, 해당 클래스의 새로운 LoxInstance를 인스턴스화하고 이를 반환합니다. `arity()` 메서드는 인터프리터가 호출 가능한 객체에 올바른 수의 인수를 전달했는지 확인하는 방식입니다. 지금은 어떤 인수도 전달할 수 없다고 가정하겠습니다. 사용자 정의 생성자에 도달하면 이 부분을 다시 다룰 것입니다.

이는 LoxInstance로 이어지는데, Lox 클래스 인스턴스의 런타임 표현입니다. 다시 말하지만, 첫 번째 구현은 작게 시작합니다.

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

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

class LoxInstance {
  private LoxClass klass;

  LoxInstance(LoxClass klass) {
    this.klass = klass;
  }

  @Override
  public String toString() {
    return klass.name + " instance";
  }
}
lox/LoxInstance.java, create new file

LoxClass와 마찬가지로, 이 역시 매우 기본적인 골격이지만 이제 막 시작했을 뿐입니다. 시험해 보고 싶다면, 다음 스크립트를 실행해 보세요:

class Bagel {}
var bagel = Bagel();
print bagel; // "Bagel instance"를 출력합니다.

이 프로그램은 많은 일을 하지는 않지만, 무언가를 시작하고 있습니다.

12 . 4인스턴스 프로퍼티

인스턴스를 만들었으니, 이제 유용하게 만들어야 합니다. 우리는 기로에 서 있습니다. 동작(메서드)을 먼저 추가할 수도 있고, 상태(프로퍼티)부터 시작할 수도 있습니다. 우리는 후자를 택할 것입니다. 왜냐하면 앞으로 보겠지만, 이 둘은 흥미로운 방식으로 얽히게 되며, 프로퍼티를 먼저 작동시키면 이들을 더 쉽게 이해할 수 있기 때문입니다.

Lox는 상태를 다루는 방식에서 JavaScript와 Python을 따릅니다. 모든 인스턴스는 이름이 지정된 값들의 열린 컬렉션입니다. 인스턴스 클래스의 메서드는 프로퍼티에 접근하고 수정할 수 있으며, 외부 코드도 마찬가지입니다. 프로퍼티는 `.` 문법을 사용하여 접근합니다.

someObject.someProperty

`.`와 식별자가 뒤따르는 표현식은 해당 표현식이 평가되는 객체에서 해당 이름의 프로퍼티를 읽습니다. 이 점(dot)은 함수 호출 표현식의 괄호와 동일한 우선순위를 가지므로, 기존 `call` 규칙을 다음으로 대체하여 문법에 삽입합니다.

callprimary ( "(" arguments? ")" | "." IDENTIFIER )* ;

기본 표현식 뒤에 괄호로 묶인 호출과 점으로 연결된 프로퍼티 접근이 혼합된 일련의 표현식을 허용합니다. '프로퍼티 접근'은 입에 잘 붙지 않으므로, 이제부터는 이를 '겟 표현식(get expression)'이라고 부르겠습니다.

12 . 4 . 1겟 표현식

다음은 구문 트리 노드입니다.

      "Call     : Expr callee, Token paren, List<Expr> arguments",
tool/GenerateAst.java
in main()
      "Get      : Expr object, Token name",
      "Grouping : Expr expression",
tool/GenerateAst.java, in main()

문법에 따라, 새로운 파싱 코드는 기존의 `call()` 메서드에 추가됩니다.

    while (true) { 
      if (match(LEFT_PAREN)) {
        expr = finishCall(expr);
lox/Parser.java
in call()
      } else if (match(DOT)) {
        Token name = consume(IDENTIFIER,
            "Expect property name after '.'.");
        expr = new Expr.Get(expr, name);
      } else {
        break;
      }
    }
lox/Parser.java, in call()

거기에 있는 바깥 `while` 루프는 문법 규칙의 `*`에 해당합니다. 괄호와 점을 발견할 때마다 토큰을 따라가며 호출과 겟(get)의 체인을 구축합니다. 예를 들어 다음과 같이요:

일련의 '.' 및 '()' 표현식을 AST로 파싱하는 과정.

새 Expr.Get 노드의 인스턴스는 리졸버로 전달됩니다.

lox/Resolver.java
add after visitCallExpr()
  @Override
  public Void visitGetExpr(Expr.Get expr) {
    resolve(expr.object);
    return null;
  }
lox/Resolver.java, add after visitCallExpr()

음, 별다른 내용은 없습니다. 프로퍼티는 동적으로 조회되므로, 리졸브되지 않습니다. 리졸브 과정에서는 점(.) 왼쪽에 있는 표현식 안으로만 재귀적으로 처리합니다. 실제 프로퍼티 접근은 인터프리터에서 발생합니다.

lox/Interpreter.java
add after visitCallExpr()
  @Override
  public Object visitGetExpr(Expr.Get expr) {
    Object object = evaluate(expr.object);
    if (object instanceof LoxInstance) {
      return ((LoxInstance) object).get(expr.name);
    }

    throw new RuntimeError(expr.name,
        "Only instances have properties.");
  }
lox/Interpreter.java, add after visitCallExpr()

먼저, 프로퍼티가 접근되는 표현식을 평가합니다. Lox에서는 클래스의 인스턴스만이 프로퍼티를 가집니다. 객체가 숫자와 같은 다른 타입인 경우, 그 객체에 대한 getter를 호출하면 런타임 오류가 발생합니다.

객체가 LoxInstance라면, 프로퍼티를 조회하도록 요청합니다. 이제 LoxInstance에 실제 상태를 부여할 때입니다. 맵(Map)으로 충분할 것입니다.

  private LoxClass klass;
lox/LoxInstance.java
in class LoxInstance
  private final Map<String, Object> fields = new HashMap<>();

  LoxInstance(LoxClass klass) {
lox/LoxInstance.java, in class LoxInstance

맵의 각 키는 프로퍼티 이름이고 해당 값은 프로퍼티 값입니다. 인스턴스에서 프로퍼티를 조회하려면:

lox/LoxInstance.java
add after LoxInstance()
  Object get(Token name) {
    if (fields.containsKey(name.lexeme)) {
      return fields.get(name.lexeme);
    }

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

다루어야 할 흥미로운 엣지 케이스는 인스턴스가 주어진 이름의 프로퍼티를 가지고 있지 않을 때 발생하는 상황입니다. `nil`과 같은 더미 값을 조용히 반환할 수도 있지만, JavaScript와 같은 언어 경험에 따르면 이런 동작은 유용하기보다는 버그를 감추는 경우가 더 많습니다. 대신, 런타임 오류로 처리할 것입니다.

따라서 가장 먼저 할 일은 인스턴스가 실제로 주어진 이름의 필드를 가지고 있는지 확인하는 것입니다. 그 후에야 반환합니다. 그렇지 않으면 오류를 발생시킵니다.

제가 '프로퍼티'에서 '필드'로 용어를 바꾼 방식에 주목하세요. 이 둘 사이에는 미묘한 차이가 있습니다. 필드는 인스턴스에 직접 저장되는 이름 있는 상태 조각입니다. 프로퍼티는 get 표현식이 반환할 수 있는 이름 있는, 음, 개체입니다. 모든 필드는 프로퍼티이지만, 나중에 보겠지만 모든 프로퍼티가 필드인 것은 아닙니다.

이론적으로는 이제 객체의 프로퍼티를 읽을 수 있습니다. 하지만 인스턴스에 실제 상태를 채워 넣을 방법이 없으므로 접근할 필드가 없습니다. 읽기 기능을 테스트하기 전에 쓰기 기능을 지원해야 합니다.

12 . 4 . 2셋 표현식

세터(setter)는 게터(getter)와 동일한 문법을 사용하지만, 할당(assignment)의 왼쪽에 나타난다는 점이 다릅니다.

someObject.someProperty = value;

문법적으로는, 할당 규칙을 확장하여 점으로 연결된 식별자가 왼쪽에 올 수 있도록 합니다.

assignment     → ( call "." )? IDENTIFIER "=" assignment
               | logic_or ;

게터와 달리, 세터는 체이닝되지 않습니다. 그러나 `call`에 대한 참조는 마지막 점(dot) 앞에 모든 수의 게터를 포함하여 높은 우선순위 표현식을 허용합니다. 예를 들어 다음과 같습니다:

breakfast.omelette.filling.meat = ham 구문 트리

여기서 마지막 부분인 `.meat`만 세터라는 점에 유의하세요. `.omelette`와 `.filling` 부분은 모두 겟(get) 표현식입니다.

변수 접근과 변수 할당에 대해 두 개의 별도 AST 노드가 있듯이, 게터 노드를 보완하는 두 번째 세터 노드가 필요합니다.

      "Logical  : Expr left, Token operator, Expr right",
tool/GenerateAst.java
in main()
      "Set      : Expr object, Token name, Expr value",
      "Unary    : Token operator, Expr right",
tool/GenerateAst.java, in main()

혹시 기억하지 못할까 봐 말씀드리지만, 파서에서 할당을 처리하는 방식은 약간 특이합니다. `=`에 도달하기 전까지는 일련의 토큰이 할당의 왼쪽임을 쉽게 알 수 없습니다. 이제 할당 문법 규칙의 왼쪽에 `call`이 있어서 임의로 큰 표현식으로 확장될 수 있으므로, 최종 `=`는 할당을 파싱하고 있다는 것을 알아야 하는 지점에서 여러 토큰 떨어져 있을 수 있습니다.

대신, 우리가 사용하는 트릭은 왼쪽을 일반 표현식으로 파싱하는 것입니다. 그런 다음 그 뒤에 등호(`=`)를 만나면, 이미 파싱한 표현식을 가져와서 할당에 대한 올바른 구문 트리 노드로 변환합니다.

그 변환에 또 다른 절을 추가하여, 왼쪽에 있는 Expr.Get 표현식을 해당 Expr.Set으로 변환하도록 처리합니다.

        return new Expr.Assign(name, value);
lox/Parser.java
in assignment()
      } else if (expr instanceof Expr.Get) {
        Expr.Get get = (Expr.Get)expr;
        return new Expr.Set(get.object, get.name, value);
      }
lox/Parser.java, in assignment()

그것이 우리 구문을 파싱하는 것입니다. 이 노드를 리졸버로 보냅니다.

lox/Resolver.java
add after visitLogicalExpr()
  @Override
  public Void visitSetExpr(Expr.Set expr) {
    resolve(expr.value);
    resolve(expr.object);
    return null;
  }
lox/Resolver.java, add after visitLogicalExpr()

다시 말하지만, Expr.Get과 마찬가지로 프로퍼티 자체는 동적으로 평가되므로, 해결할 것이 없습니다. 우리가 해야 할 일은 Expr.Set의 두 하위 표현식, 즉 프로퍼티가 설정되는 객체와 설정될 값으로 재귀적으로 들어가는 것뿐입니다.

그것은 우리를 인터프리터로 이끕니다.

lox/Interpreter.java
add after visitLogicalExpr()
  @Override
  public Object visitSetExpr(Expr.Set expr) {
    Object object = evaluate(expr.object);

    if (!(object instanceof LoxInstance)) { 
      throw new RuntimeError(expr.name,
                             "Only instances have fields.");
    }

    Object value = evaluate(expr.value);
    ((LoxInstance)object).set(expr.name, value);
    return value;
  }
lox/Interpreter.java, add after visitLogicalExpr()

프로퍼티가 설정되는 객체를 평가하고 그것이 LoxInstance인지 확인합니다. 그렇지 않으면 런타임 오류입니다. 그렇지 않으면 설정될 값을 평가하고 인스턴스에 저장합니다. 이는 LoxInstance의 새로운 메서드에 의존합니다.

lox/LoxInstance.java
add after get()
  void set(Token name, Object value) {
    fields.put(name.lexeme, value);
  }
lox/LoxInstance.java, add after get()

여기에는 특별한 마법은 없습니다. 값들을 필드가 있는 Java 맵에 바로 채워 넣습니다. Lox는 인스턴스에 새로운 필드를 자유롭게 생성하는 것을 허용하므로, 키가 이미 존재하는지 확인할 필요가 없습니다.

12 . 5클래스 메서드

클래스의 인스턴스를 생성하고 데이터를 채워 넣을 수는 있지만, 클래스 자체는 실제로 아무것도 하지 않습니다. 인스턴스는 단지 맵일 뿐이며 모든 인스턴스는 거의 동일합니다. 이들을 클래스의 인스턴스처럼 느끼게 하려면 동작, 즉 메서드가 필요합니다.

우리의 유용한 파서는 이미 메서드 선언을 파싱하므로, 이 부분은 괜찮습니다. 또한 메서드 호출을 위한 새로운 파서 지원도 추가할 필요가 없습니다. 이미 `.` (게터)와 `()` (함수 호출)가 있습니다. '메서드 호출'은 단순히 이들을 연결하는 것입니다.

'object.method(argument)'에 대한 구문 트리

여기서 흥미로운 질문이 생깁니다. 이 두 표현식이 분리되면 어떻게 될까요? 이 예시에서 `method`가 인스턴스의 필드가 아니라 `object` 클래스의 메서드라고 가정할 때, 다음 코드 조각은 어떻게 동작해야 할까요?

var m = object.method;
m(argument);

이 프로그램은 메서드를 '찾아서' 그 결과(무엇이든)를 변수에 저장한 다음 나중에 해당 객체를 호출합니다. 이것이 허용될까요? 메서드를 인스턴스의 함수처럼 취급할 수 있을까요?

반대 방향은 어떨까요?

class Box {}

fun notMethod(argument) {
  print "called function with " + argument;
}

var box = Box();
box.function = notMethod;
box.function("argument");

이 프로그램은 인스턴스를 생성한 다음 그 위에 필드에 함수를 저장합니다. 그리고 나서 메서드 호출과 동일한 구문을 사용하여 해당 함수를 호출합니다. 이것이 작동할까요?

다른 언어들은 이 질문들에 대해 서로 다른 답변을 가지고 있습니다. 이에 대해 논문을 쓸 수도 있을 것입니다. Lox의 경우, 이 두 가지 질문 모두에 대한 답은 '예, 작동합니다'입니다. 이를 정당화할 몇 가지 이유가 있습니다. 두 번째 예시(필드에 저장된 함수 호출)의 경우, 일급 함수(first-class functions)는 유용하며 필드에 저장하는 것은 완벽하게 일반적인 일이므로 이를 지원하고자 합니다.

첫 번째 예시는 더 모호합니다. 한 가지 동기는 사용자들이 일반적으로 프로그램의 의미를 변경하지 않고 하위 표현식을 지역 변수로 끌어올릴 수 있기를 기대한다는 것입니다. 다음 코드를 생각해 보세요:

breakfast(omelette.filledWith(cheese), sausage);

그리고 다음으로 변경하면:

var eggs = omelette.filledWith(cheese);
breakfast(eggs, sausage);

동일하게 작동합니다. 마찬가지로, 메서드 호출에서 `.`과 `()`가 두 개의 별개 표현식이므로, 조회 부분을 변수로 끌어올린 다음 나중에 호출할 수 있어야 할 것 같습니다. 메서드를 조회할 때 얻게 되는 이 무엇이며, 다음과 같은 이상한 경우에도 어떻게 동작하는지 신중하게 생각해야 합니다:

class Person {
  sayName() {
    print this.name;
  }
}

var jane = Person();
jane.name = "Jane";

var method = jane.sayName;
method(); // ?

어떤 인스턴스의 메서드 핸들을 잡고 나중에 호출하면, 그 메서드는 자신이 원래 가져온 인스턴스를 '기억'할까요? 메서드 안의 `this`가 여전히 그 원래 객체를 참조할까요?

다음은 여러분의 머리를 더 복잡하게 만들 수 있는 병적인 예시입니다:

class Person {
  sayName() {
    print this.name;
  }
}

var jane = Person();
jane.name = "Jane";

var bill = Person();
bill.name = "Bill";

bill.sayName = jane.sayName;
bill.sayName(); // ?

마지막 줄은 'Bill'을 출력할까요? 그게 메서드를 호출한 인스턴스이기 때문일까요? 아니면 'Jane'을 출력할까요? 그게 우리가 처음 메서드를 가져온 인스턴스이기 때문일까요?

Lua와 JavaScript의 동일한 코드는 'Bill'을 출력할 것입니다. 이 언어들은 '메서드'라는 개념이 사실상 없습니다. 모든 것이 일종의 필드 안의 함수이므로, `jane`이 `sayName`을 `bill`보다 더 '소유한다'고 보기는 어렵습니다.

하지만 Lox는 실제 클래스 구문을 가지고 있으므로, 어떤 호출 가능한 것이 메서드이고 어떤 것이 함수인지 알 수 있습니다. 따라서 Python, C# 등과 같이, 메서드가 처음 사용될 때 `this`를 원래 인스턴스에 '바인딩'할 것입니다. Python은 이를 바운드 메서드(bound methods)라고 부릅니다.

실제로 대부분의 경우 이것이 여러분이 원하는 동작입니다. 어떤 객체의 메서드 참조를 가져와 나중에 콜백으로 사용한다면, 그 콜백이 다른 객체의 필드에 저장되더라도 원래 속해 있던 인스턴스를 기억하고 싶을 것입니다.

좋습니다, 머리에 로드할 의미론적 내용이 많네요. 잠시 엣지 케이스는 잊어버리세요. 나중에 다시 다룰 것입니다. 지금은 기본적인 메서드 호출이 작동하도록 만들겠습니다. 클래스 본문 안의 메서드 선언은 이미 파싱하고 있으므로, 다음 단계는 이들을 리졸브하는 것입니다.

    define(stmt.name);

lox/Resolver.java
in visitClassStmt()
    for (Stmt.Function method : stmt.methods) {
      FunctionType declaration = FunctionType.METHOD;
      resolveFunction(method, declaration); 
    }

    return null;
lox/Resolver.java, in visitClassStmt()

클래스 본문 내의 메서드들을 반복하며, 이미 함수 선언을 처리하기 위해 작성했던 `resolveFunction()` 메서드를 호출합니다. 유일한 차이점은 새로운 FunctionType enum 값을 전달한다는 것입니다.

    NONE,
    FUNCTION,
lox/Resolver.java
in enum FunctionType
add “,” to previous line
    METHOD
  }
lox/Resolver.java, in enum FunctionType, add “,” to previous line

`this` 표현식을 리졸브할 때 이것이 중요해질 것입니다. 지금은 걱정하지 마세요. 흥미로운 부분은 인터프리터에 있습니다.

    environment.define(stmt.name.lexeme, null);
lox/Interpreter.java
in visitClassStmt()
replace 1 line
    Map<String, LoxFunction> methods = new HashMap<>();
    for (Stmt.Function method : stmt.methods) {
      LoxFunction function = new LoxFunction(method, environment);
      methods.put(method.name.lexeme, function);
    }

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

클래스 선언 문을 인터프리트할 때, 클래스의 구문적 표현(AST 노드)을 런타임 표현으로 변환합니다. 이제 클래스에 포함된 메서드에 대해서도 동일한 작업을 수행해야 합니다. 각 메서드 선언은 LoxFunction 객체로 피어납니다.

이 모든 것들을 메서드 이름을 키로 하는 맵(map)으로 묶습니다. 이는 LoxClass에 저장됩니다.

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

  LoxClass(String name, Map<String, LoxFunction> methods) {
    this.name = name;
    this.methods = methods;
  }

  @Override
  public String toString() {
lox/LoxClass.java, in class LoxClass, replace 4 lines

인스턴스가 상태를 저장하는 곳이라면, 클래스는 동작을 저장합니다. LoxInstance는 필드 맵을 가지고 있으며, LoxClass는 메서드 맵을 가집니다. 메서드가 클래스에 소유되어 있더라도, 해당 클래스의 인스턴스를 통해 접근됩니다.

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

lox/LoxInstance.java
in get()
    LoxFunction method = klass.findMethod(name.lexeme);
    if (method != null) return method;

    throw new RuntimeError(name, 
        "Undefined property '" + name.lexeme + "'.");
lox/LoxInstance.java, in get()

인스턴스에서 프로퍼티를 조회할 때, 일치하는 필드를 찾지 못하면 인스턴스 클래스에서 해당 이름의 메서드를 찾습니다. 찾으면 그 메서드를 반환합니다. 여기서 '필드'와 '프로퍼티'의 구분이 의미를 갖게 됩니다. 프로퍼티에 접근할 때, 필드(인스턴스에 저장된 상태의 일부)를 얻을 수도 있고, 인스턴스 클래스에 정의된 메서드를 얻을 수도 있습니다.

메서드는 다음을 사용하여 조회됩니다:

lox/LoxClass.java
add after LoxClass()
  LoxFunction findMethod(String name) {
    if (methods.containsKey(name)) {
      return methods.get(name);
    }

    return null;
  }
lox/LoxClass.java, add after LoxClass()

아마 이 메서드가 나중에 더 흥미로워질 것이라고 짐작하실 겁니다. 지금은 클래스의 메서드 테이블에서 간단한 맵 조회를 하는 것으로 충분합니다. 한번 시도해보세요:

class Bacon {
  eat() {
    print "Crunch crunch crunch!";
  }
}

Bacon().eat(); // "Crunch crunch crunch!"를 출력합니다.

12 . 6this

객체에 동작과 상태를 모두 정의할 수 있지만, 아직 이들이 서로 연결되어 있지는 않습니다. 메서드 내부에서는 '현재' 객체, 즉 메서드가 호출된 인스턴스의 필드에 접근할 방법이 없으며, 같은 객체의 다른 메서드를 호출할 수도 없습니다.

해당 인스턴스에 접근하려면 이름이 필요합니다. Smalltalk, Ruby, Swift는 'self'를 사용합니다. Simula, C++, Java 등은 'this'를 사용합니다. Python은 관례적으로 'self'를 사용하지만, 기술적으로는 원하는 대로 부를 수 있습니다.

Lox에서는 대체로 Java 스타일을 따르므로 'this'를 사용하겠습니다. 메서드 본문 내에서 `this` 표현식은 메서드가 호출된 인스턴스로 평가됩니다. 또는 더 구체적으로 말하자면, 메서드가 두 단계(접근 및 호출)로 이루어지므로, 메서드가 접근된 객체를 참조할 것입니다.

그것 때문에 우리의 작업이 더 어려워집니다. 다음을 살펴보세요:

class Egotist {
  speak() {
    print this;
  }
}

var method = Egotist().speak;
method();

끝에서 두 번째 줄에서 클래스 인스턴스에서 `speak()` 메서드의 참조를 가져옵니다. 이는 함수를 반환하며, 이 함수는 자신이 가져온 인스턴스를 기억해야 합니다. 그래야 나중에 마지막 줄에서 함수가 호출될 때도 여전히 그 인스턴스를 찾을 수 있습니다.

메서드가 접근되는 시점에 `this`를 가져와서 함수에 어떤 식으로든 첨부하여, 필요한 만큼 유지되도록 해야 합니다. 음... 함수 주위에 매달려 있는 추가 데이터를 저장하는 방법이라? 클로저와 아주 많이 닮았지 않나요?

만약 메서드를 조회할 때 반환되는 함수를 둘러싸는 환경 안에 `this`를 일종의 숨겨진 변수로 정의한다면, 본문 내의 `this` 사용은 나중에 이를 찾을 수 있을 것입니다. LoxFunction은 이미 주변 환경을 유지할 수 있는 능력을 가지고 있으므로, 필요한 메커니즘은 갖춰져 있습니다.

어떻게 작동하는지 예시를 통해 살펴보겠습니다:

class Cake {
  taste() {
    var adjective = "delicious";
    print "The " + this.flavor + " cake is " + adjective + "!";
  }
}

var cake = Cake();
cake.flavor = "German chocolate";
cake.taste(); // "The German chocolate cake is delicious!"를 출력합니다.

클래스 정의를 처음 평가할 때, `taste()`를 위한 LoxFunction을 생성합니다. 이 LoxFunction의 클로저는 클래스를 둘러싸는 환경이며, 이 경우 전역 환경입니다. 따라서 클래스의 메서드 맵에 저장하는 LoxFunction은 다음과 같습니다:

메서드의 초기 클로저.

`cake.taste` get 표현식을 평가할 때, `this`를 메서드가 접근된 객체(여기서는 `cake`)에 바인딩하는 새로운 환경을 생성합니다. 그런 다음 원래 함수와 동일한 코드를 사용하지만, 이 새로운 환경을 클로저로 사용하는 새로운 LoxFunction을 만듭니다.

'this'를 바인딩하는 새로운 클로저.

이 LoxFunction은 메서드 이름에 대한 get 표현식을 평가할 때 반환됩니다. 나중에 `()` 표현식에 의해 해당 함수가 호출될 때, 평소와 같이 메서드 본문을 위한 환경을 생성합니다.

바운드 메서드를 호출하고 메서드 본문을 위한 새 환경 생성.

본문 환경의 부모는 이전에 `this`를 현재 객체에 바인딩하기 위해 생성한 환경입니다. 따라서 본문 내부에서 `this`를 사용하는 것은 해당 인스턴스로 성공적으로 리졸브됩니다.

`this`를 구현하기 위해 기존 환경 코드를 재사용하면 메서드와 함수가 상호작용하는 흥미로운 경우도 처리됩니다. 예를 들어 다음과 같습니다:

class Thing {
  getCallback() {
    fun localFunction() {
      print this;
    }

    return localFunction;
  }
}

var callback = Thing().getCallback();
callback();

예를 들어 JavaScript에서는 메서드 내부에서 콜백을 반환하는 것이 일반적입니다. 이 콜백은 원래 객체, 즉 메서드가 연관된 `this` 값을 유지하고 접근하기를 원할 수 있습니다. 클로저 및 환경 체인에 대한 기존 지원은 이 모든 것을 올바르게 처리해야 합니다.

이제 코드를 작성해 봅시다. 첫 번째 단계는 `this`에 대한 새로운 구문을 추가하는 것입니다.

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

우리 렉서가 이미 예약어로 인식하는 단일 토큰이므로 파싱은 간단합니다.

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

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

리졸버에 도달하면 `this`가 변수처럼 작동하는 방식을 이해하기 시작할 것입니다.

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

lox/Resolver.java, add after visitSetExpr()

우리는 'this'를 '변수'의 이름으로 사용하여 다른 지역 변수와 똑같이 리졸브합니다. 물론 지금은 'this'가 어떤 스코프에도 선언되어 있지 않으므로 제대로 작동하지 않을 것입니다. `visitClassStmt()`에서 이 문제를 해결해 봅시다.

    define(stmt.name);

lox/Resolver.java
in visitClassStmt()
    beginScope();
    scopes.peek().put("this", true);

    for (Stmt.Function method : stmt.methods) {
lox/Resolver.java, in visitClassStmt()

메서드 본문을 리졸브하기 시작하기 전에, 새로운 스코프를 푸시하고 그 안에 'this'를 마치 변수인 것처럼 정의합니다. 그런 다음 작업이 끝나면 해당 둘러싼 스코프를 폐기합니다.

    }

lox/Resolver.java
in visitClassStmt()
    endScope();

    return null;
lox/Resolver.java, in visitClassStmt()

이제 `this` 표현식이 (최소한 메서드 내부에서) 마주칠 때마다, 메서드 본문의 블록 바로 바깥의 암묵적인 스코프에 정의된 '지역 변수'로 리졸브될 것입니다.

리졸버는 `this`를 위한 새로운 스코프를 가지므로, 인터프리터는 이에 상응하는 환경을 생성해야 합니다. 항상 리졸버의 스코프 체인과 인터프리터의 연결된 환경을 서로 동기화해야 한다는 점을 기억하세요. 런타임에, 우리는 인스턴스에서 메서드를 찾은 후에 환경을 생성합니다. 단순히 메서드의 LoxFunction을 반환하던 이전 코드를 다음으로 대체합니다:

    LoxFunction method = klass.findMethod(name.lexeme);
lox/LoxInstance.java
in get()
replace 1 line
    if (method != null) return method.bind(this);

    throw new RuntimeError(name, 
        "Undefined property '" + name.lexeme + "'.");
lox/LoxInstance.java, in get(), replace 1 line

새로운 `bind()` 호출에 주목하세요. 이는 다음과 같습니다:

lox/LoxFunction.java
add after LoxFunction()
  LoxFunction bind(LoxInstance instance) {
    Environment environment = new Environment(closure);
    environment.define("this", instance);
    return new LoxFunction(declaration, environment);
  }
lox/LoxFunction.java, add after LoxFunction()

별다른 어려운 부분은 없습니다. 메서드의 원래 클로저 안에 중첩된 새로운 환경을 생성합니다. 일종의 클로저 안의 클로저죠. 메서드가 호출될 때, 이 환경은 메서드 본문 환경의 부모가 될 것입니다.

우리는 그 환경에서 'this'를 변수로 선언하고 주어진 인스턴스, 즉 메서드가 접근되는 인스턴스에 바인딩합니다. 이것 보세요, 이제 반환된 LoxFunction은 'this'가 객체에 바인딩된 자신만의 작은 영속적인 세계를 가지고 다니게 됩니다.

남은 작업은 `this` 표현식을 인터프리트하는 것입니다. 리졸버와 마찬가지로, 이는 변수 표현식을 인터프리트하는 것과 동일합니다.

lox/Interpreter.java
add after visitSetExpr()
  @Override
  public Object visitThisExpr(Expr.This expr) {
    return lookUpVariable(expr.keyword, expr);
  }
lox/Interpreter.java, add after visitSetExpr()

이전에 본 케이크 예시를 사용하여 직접 시도해 보세요. 스무 줄도 안 되는 코드로, 우리 인터프리터는 중첩 클래스, 메서드 내 함수, 메서드 핸들 등과 상호작용할 수 있는 모든 이상한 방법에서조차 메서드 내부의 `this`를 처리합니다.

12 . 6 . 1this의 잘못된 사용

잠깐만요. 메서드 외부에서 `this`를 사용하려고 하면 어떻게 될까요? 다음과 같은 경우는요:

print this;

아니면:

fun notAMethod() {
  print this;
}

메서드 안에 있지 않으면 `this`가 가리킬 인스턴스가 없습니다. `nil`과 같은 기본값을 주거나 런타임 오류로 만들 수도 있지만, 사용자는 명백히 실수를 저질렀습니다. 그들이 이 실수를 빨리 찾아 고칠수록 더 행복해질 것입니다.

우리 리졸브 단계는 이 오류를 정적으로 감지하기에 좋은 곳입니다. 이미 함수 외부의 `return` 문을 감지하고 있습니다. `this`에 대해서도 유사한 작업을 할 것입니다. 기존 FunctionType enum의 맥락에서, 새로운 ClassType enum을 정의합니다.

  }
lox/Resolver.java
add after enum FunctionType

  private enum ClassType {
    NONE,
    CLASS
  }

  private ClassType currentClass = ClassType.NONE;

  void resolve(List<Stmt> statements) {
lox/Resolver.java, add after enum FunctionType

네, 부울(Boolean) 값일 수도 있습니다. 하지만 상속(inheritance)에 도달하면 세 번째 값이 추가될 것이므로 지금은 enum을 사용합니다. 또한 해당 필드인 `currentClass`를 추가합니다. 이 값은 구문 트리를 탐색하는 동안 현재 클래스 선언 내부에 있는지 여부를 알려줍니다. `NONE`으로 시작하며, 이는 클래스 내부에 있지 않음을 의미합니다.

클래스 선언을 리졸브하기 시작할 때, 이 값을 변경합니다.

  public Void visitClassStmt(Stmt.Class stmt) {
lox/Resolver.java
in visitClassStmt()
    ClassType enclosingClass = currentClass;
    currentClass = ClassType.CLASS;

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

`currentFunction`과 마찬가지로, 필드의 이전 값을 지역 변수에 저장합니다. 이를 통해 JVM을 활용하여 `currentClass` 값의 스택을 유지할 수 있습니다. 이렇게 하면 한 클래스가 다른 클래스 안에 중첩되더라도 이전 값을 잃지 않습니다.

메서드가 리졸브되면, 이전 값을 복원하여 스택을 '팝'합니다.

    endScope();

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

`this` 표현식을 리졸브할 때, `currentClass` 필드는 표현식이 메서드 본문 안에 중첩되어 있지 않으면 오류를 보고하는 데 필요한 정보를 제공합니다.

  public Void visitThisExpr(Expr.This expr) {
lox/Resolver.java
in visitThisExpr()
    if (currentClass == ClassType.NONE) {
      Lox.error(expr.keyword,
          "Can't use 'this' outside of a class.");
      return null;
    }

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

이는 사용자가 `this`를 올바르게 사용하는 데 도움이 될 것이며, 인터프리터에서 런타임에 잘못된 사용을 처리할 필요를 덜어줍니다.

12 . 7생성자 및 초기화 메서드

이제 클래스를 사용하여 거의 모든 것을 할 수 있으며, 챕터의 끝에 다다르면서 우리는 이상하게도 시작에 집중하게 됩니다. 메서드와 필드를 통해 상태와 동작을 함께 캡슐화하여 객체가 항상 유효한 구성으로 유지되도록 할 수 있습니다. 하지만 완전히 새로운 객체가 좋은 상태로 시작하는 것을 어떻게 보장할 수 있을까요?

이를 위해서는 생성자가 필요합니다. 저는 생성자가 언어를 설계하는 데 있어 가장 까다로운 부분 중 하나라고 생각하며, 다른 대부분의 언어를 자세히 들여다보면 객체 생성 주변에서 설계의 이음새가 완벽하게 들어맞지 않는 을 발견할 수 있을 것입니다. 아마도 탄생의 순간에 본질적으로 지저분한 무언가가 있는지도 모릅니다.

객체를 '생성'하는 것은 실제로는 두 가지 연산의 조합입니다:

  1. 런타임이 새 인스턴스에 필요한 메모리를 할당합니다. 대부분의 언어에서 이 연산은 사용자 코드가 접근할 수 있는 수준보다 훨씬 근본적인 수준에서 이루어집니다.

  2. 그다음, 형성되지 않은 객체를 초기화하는 사용자 제공 코드 조각이 호출됩니다.

후자는 우리가 '생성자'라고 들을 때 떠올리는 것이지만, 언어 자체는 우리가 그 시점에 도달하기 전에 이미 몇 가지 기초 작업을 수행한 경우가 많습니다. 사실, 우리 Lox 인터프리터는 새로운 LoxInstance 객체를 생성할 때 이미 이 부분을 처리하고 있습니다.

남은 부분, 즉 사용자 정의 초기화를 지금부터 하겠습니다. 언어마다 클래스의 새로운 객체를 설정하는 코드 조각에 대해 다양한 표기법이 있습니다. C++, Java, C#은 클래스 이름과 일치하는 이름의 메서드를 사용합니다. Ruby와 Python은 이를 `init()`이라고 부릅니다. 후자가 멋지고 간결하므로, 우리도 그렇게 하겠습니다.

LoxClass의 LoxCallable 구현에 몇 줄을 더 추가합니다.

                     List<Object> arguments) {
    LoxInstance instance = new LoxInstance(this);
lox/LoxClass.java
in call()
    LoxFunction initializer = findMethod("init");
    if (initializer != null) {
      initializer.bind(instance).call(interpreter, arguments);
    }

    return instance;
lox/LoxClass.java, in call()

클래스가 호출되면, LoxInstance가 생성된 후 'init' 메서드를 찾습니다. 만약 찾으면, 일반적인 메서드 호출처럼 즉시 바인딩하고 호출합니다. 인수 목록은 그대로 전달됩니다.

인수 목록은 클래스가 자신의 `arity`를 선언하는 방식도 조정해야 함을 의미합니다.

  public int arity() {
lox/LoxClass.java
in arity()
replace 1 line
    LoxFunction initializer = findMethod("init");
    if (initializer == null) return 0;
    return initializer.arity();
  }
lox/LoxClass.java, in arity(), replace 1 line

초기화 메서드가 있다면, 해당 메서드의 `arity`가 클래스 자체를 호출할 때 몇 개의 인수를 전달해야 하는지 결정합니다. 하지만 편의를 위해 클래스가 초기화 메서드를 정의하도록 요구하지는 않습니다. 초기화 메서드가 없으면 `arity`는 여전히 0입니다.

기본적으로 이것으로 끝입니다. `init()` 메서드를 호출하기 전에 바인딩하므로, 메서드 본문 안에서 `this`에 접근할 수 있습니다. 이것과 클래스에 전달된 인수들이면 새로운 인스턴스를 원하는 대로 설정할 수 있는 모든 것입니다.

12 . 7 . 1init() 직접 호출하기

늘 그렇듯이, 이 새로운 의미론적 영역을 탐험하면 몇 가지 이상한 문제가 발생합니다. 다음을 생각해 보세요:

class Foo {
  init() {
    print this;
  }
}

var foo = Foo();
print foo.init();

`init()` 메서드를 직접 호출하여 객체를 '재초기화'할 수 있을까요? 그렇게 한다면 무엇을 반환할까요? 본문이 반환하는 것처럼 보이므로 합리적인 답변은 `nil`일 것입니다.

하지만구현 편의를 위해 타협하는 것을 일반적으로 싫어하지만`init()` 메서드가 직접 호출될 때도 항상 `this`를 반환한다고 말하면 clox의 생성자 구현이 훨씬 쉬워질 것입니다. jlox가 이에 호환되도록 하기 위해 LoxFunction에 약간의 특별한 케이스 코드를 추가합니다.

      return returnValue.value;
    }
lox/LoxFunction.java
in call()
    if (isInitializer) return closure.getAt(0, "this");
    return null;
lox/LoxFunction.java, in call()

함수가 초기화 메서드인 경우, 실제 반환 값을 재정의하고 강제로 `this`를 반환합니다. 이는 새로운 `isInitializer` 필드에 의존합니다.

  private final Environment closure;

lox/LoxFunction.java
in class LoxFunction
replace 1 line
  private final boolean isInitializer;

  LoxFunction(Stmt.Function declaration, Environment closure,
              boolean isInitializer) {
    this.isInitializer = isInitializer;
    this.closure = closure;
    this.declaration = declaration;
lox/LoxFunction.java, in class LoxFunction, replace 1 line

LoxFunction의 이름이 'init'인지 단순히 확인만 할 수는 없습니다. 사용자가 그 이름으로 함수를 정의했을 수도 있기 때문입니다. 그 경우에는 반환할 `this`가 없습니다. 그 이상한 엣지 케이스를 피하기 위해, LoxFunction이 초기화 메서드를 나타내는지 여부를 직접 저장할 것입니다. 이는 LoxFunction을 생성하는 몇 군데를 수정해야 한다는 의미입니다.

  public Void visitFunctionStmt(Stmt.Function stmt) {
lox/Interpreter.java
in visitFunctionStmt()
replace 1 line
    LoxFunction function = new LoxFunction(stmt, environment,
                                           false);
    environment.define(stmt.name.lexeme, function);
lox/Interpreter.java, in visitFunctionStmt(), replace 1 line

실제 함수 선언의 경우, `isInitializer`는 항상 false입니다. 메서드의 경우, 이름을 확인합니다.

    for (Stmt.Function method : stmt.methods) {
lox/Interpreter.java
in visitClassStmt()
replace 1 line
      LoxFunction function = new LoxFunction(method, environment,
          method.name.lexeme.equals("init"));
      methods.put(method.name.lexeme, function);
lox/Interpreter.java, in visitClassStmt(), replace 1 line

그리고 `this`를 메서드에 바인딩하는 클로저를 생성하는 `bind()`에서는 원래 메서드의 값을 전달합니다.

    environment.define("this", instance);
lox/LoxFunction.java
in bind()
replace 1 line
    return new LoxFunction(declaration, environment,
                           isInitializer);
  }
lox/LoxFunction.java, in bind(), replace 1 line

12 . 7 . 2init()에서 반환하기

아직 완전히 해결된 것은 아닙니다. 우리는 사용자 작성 초기화 메서드가 명시적으로 값을 반환하지 않는다고 가정해 왔습니다. 대부분의 생성자가 그렇기 때문입니다. 사용자가 다음과 같이 시도하면 어떻게 될까요?

class Foo {
  init() {
    return "something else";
  }
}

이것은 분명히 사용자가 원하는 대로 작동하지 않을 것이므로, 정적 오류로 만드는 것이 좋습니다. 리졸버로 돌아가 FunctionType에 또 다른 케이스를 추가합니다.

    FUNCTION,
lox/Resolver.java
in enum FunctionType
    INITIALIZER,
    METHOD
lox/Resolver.java, in enum FunctionType

방문한 메서드의 이름을 사용하여 초기화 메서드를 리졸브하는지 여부를 결정합니다.

      FunctionType declaration = FunctionType.METHOD;
lox/Resolver.java
in visitClassStmt()
      if (method.name.lexeme.equals("init")) {
        declaration = FunctionType.INITIALIZER;
      }

      resolveFunction(method, declaration); 
lox/Resolver.java, in visitClassStmt()

나중에 `return` 문으로 이동할 때, 해당 필드를 확인하여 `init()` 메서드 내부에서 값을 반환하는 것을 오류로 처리합니다.

    if (stmt.value != null) {
lox/Resolver.java
in visitReturnStmt()
      if (currentFunction == FunctionType.INITIALIZER) {
        Lox.error(stmt.keyword,
            "Can't return a value from an initializer.");
      }

      resolve(stmt.value);
lox/Resolver.java, in visitReturnStmt()

우리는 아직 끝나지 않았습니다. 초기화 메서드에서 을 반환하는 것을 정적으로 허용하지 않지만, 빈 조기 `return`을 여전히 사용할 수 있습니다.

class Foo {
  init() {
    return;
  }
}

사실 이는 때때로 유용하므로, 완전히 금지하고 싶지는 않습니다. 대신, `nil` 대신 `this`를 반환해야 합니다. 이는 LoxFunction에서 쉽게 고칠 수 있는 부분입니다.

    } catch (Return returnValue) {
lox/LoxFunction.java
in call()
      if (isInitializer) return closure.getAt(0, "this");

      return returnValue.value;
lox/LoxFunction.java, in call()

만약 초기화 메서드 안에 있고 `return` 문을 실행하면, 값(`nil`일 것입니다)을 반환하는 대신, 다시 `this`를 반환합니다.

휴! 많은 작업이었지만, 그 보상으로 우리 작은 인터프리터는 완전히 새로운 프로그래밍 패러다임을 갖추게 되었습니다. 클래스, 메서드, 필드, `this`, 그리고 생성자까지요. 우리의 아기 언어가 꽤 어른스러워 보이네요.

연습 문제

  1. 인스턴스에 메서드를 가지고 있지만, 클래스 객체 자체에서 직접 호출할 수 있는 '정적' 메서드를 정의할 방법이 없습니다. 이를 지원하도록 추가하세요. 메서드 앞에 `class` 키워드를 사용하여 클래스 객체에 속하는 정적 메서드를 나타내도록 하세요.

    class Math {
      class square(n) {
        return n * n;
      }
    }
    
    print Math.square(3); // "9"를 출력합니다.
    

    이 문제는 원하는 방식으로 해결할 수 있지만, Smalltalk와 Ruby에서 사용하는 '메타클래스(metaclasses)'는 특히 우아한 접근 방식입니다. 힌트: LoxClass가 LoxInstance를 상속하도록 만들고 거기서부터 시작하세요.

  2. 대부분의 현대 언어는 '게터'와 '세터'를 지원합니다필드 읽기 및 쓰기처럼 보이지만 실제로는 사용자 정의 코드를 실행하는 클래스 멤버입니다. Lox를 확장하여 게터 메서드를 지원하세요. 이들은 매개변수 목록 없이 선언됩니다. 해당 이름의 프로퍼티에 접근할 때 게터의 본문이 실행됩니다.

    class Circle {
      init(radius) {
        this.radius = radius;
      }
    
      area {
        return 3.141592653 * this.radius * this.radius;
      }
    }
    
    var circle = Circle(4);
    print circle.area; // 대략 "50.2655"를 출력합니다.
    
  3. Python과 JavaScript는 객체의 필드를 자신의 메서드 외부에서 자유롭게 접근할 수 있도록 허용합니다. Ruby와 Smalltalk는 인스턴스 상태를 캡슐화합니다. 클래스의 메서드만이 원시 필드에 접근할 수 있으며, 어떤 상태를 노출할지는 클래스에 달려 있습니다. 대부분의 정적 타입 언어는 `private` 및 `public`과 같은 접근 제어자를 제공하여 클래스의 특정 멤버가 외부에서 접근 가능한지 제어합니다.

    이러한 접근 방식들 간의 장단점은 무엇이며, 언어가 왜 한쪽을 다른 쪽보다 선호할 수 있을까요?

디자인 노트: 프로토타입과 파워

이 챕터에서는 LoxClass와 LoxInstance라는 두 가지 새로운 런타임 엔티티를 소개했습니다. 전자는 객체의 동작이 존재하는 곳이고, 후자는 상태를 위한 것입니다. 만약 단일 객체, 즉 LoxInstance 내부에 직접 메서드를 정의할 수 있다면 어떨까요? 그 경우 LoxClass는 전혀 필요 없을 것입니다. LoxInstance 자체가 객체의 동작과 상태를 정의하는 완전한 패키지가 될 것입니다.

그래도 클래스 없이 여러 인스턴스 간에 동작을 재사용할 수 있는 방법이 필요할 것입니다. 한 LoxInstance가 다른 LoxInstance에 직접 위임(delegate)하여 필드와 메서드를 재사용하도록 할 수 있습니다. 마치 상속과 비슷하게요.

사용자들은 프로그램을 객체들의 집합으로 모델링할 것입니다. 이 객체들 중 일부는 공통점을 반영하기 위해 서로에게 위임합니다. 위임자로 사용되는 객체들은 다른 객체들이 정제하는 '표준' 또는 '프로토타입' 객체를 나타냅니다. 그 결과, 내부적으로는 단일 구성 요소인 LoxInstance만을 가진 더 단순한 런타임이 됩니다.

이것이 바로 이 패러다임의 이름인 프로토타입이 유래한 곳입니다. 데이비드 언가(David Ungar)와 랜달 스미스(Randall Smith)가 Self라는 언어에서 이를 발명했습니다. 그들은 Smalltalk에서 시작하여 얼마나 간소화할 수 있는지 알아보기 위해 위와 같은 사고 실험을 거쳐 이를 고안했습니다.

프로토타입은 오랫동안 학문적인 호기심이었고, 흥미로운 연구를 촉발했지만 더 큰 프로그래밍 세계에는 큰 영향을 미치지 못했습니다. 브렌던 아이크(Brendan Eich)가 JavaScript에 프로토타입을 밀어 넣기 전까지는 말이죠. 그리고 JavaScript는 즉시 전 세계를 장악했습니다. JavaScript의 프로토타입에 대해서는 많은(수많은) 글이 쓰였습니다. 그것이 프로토타입이 훌륭하다는 것을 보여주는지 아니면 혼란스럽다는 것을 보여주는지아니면 둘 다!는 여전히 열린 질문입니다.

저는 프로토타입이 언어에 좋은 아이디어인지 아닌지에 대해 깊이 논하지 않겠습니다. 저는 프로토타입 기반 언어와 클래스 기반 언어를 모두 만들어 보았고, 둘에 대한 제 의견은 복잡합니다. 제가 논하고 싶은 것은 언어에서 단순성의 역할입니다.

프로토타입은 클래스보다 더 단순합니다언어 구현자가 작성할 코드가 적고, 사용자가 배우고 이해할 개념이 더 적습니다. 그렇다고 해서 더 낫다고 할 수 있을까요? 우리 언어 덕후들은 미니멀리즘을 숭배하는 경향이 있습니다. 개인적으로 저는 단순성이 방정식의 일부일 뿐이라고 생각합니다. 우리가 사용자에게 진정으로 주고 싶은 것은 파워(power)이며, 저는 이를 다음과 같이 정의합니다:

power = breadth × ease ÷ complexity

이들 중 어느 것도 정확한 수치 측정값이 아닙니다. 여기서 수학은 실제 정량화가 아닌 비유로 사용하고 있습니다.

  • 폭(Breadth)은 언어가 표현할 수 있는 다양한 것들의 범위입니다. C는 폭이 넓습니다운영 체제부터 사용자 애플리케이션, 게임에 이르기까지 모든 것에 사용되었습니다. AppleScript나 Matlab과 같은 도메인 특화 언어는 폭이 좁습니다.

  • 용이성(Ease)은 언어가 원하는 작업을 수행하게 하는 데 드는 노력의 정도입니다. '유용성(Usability)'이라는 용어도 있을 수 있지만, 제가 도입하고 싶지 않은 더 많은 함의를 가지고 있습니다. '고수준' 언어는 '저수준' 언어보다 용이성이 높은 경향이 있습니다. 대부분의 언어는 어떤 것들이 다른 것들보다 표현하기 더 쉽다는 '결(grain)'을 가지고 있습니다.

  • 복잡성(Complexity)은 언어(런타임, 핵심 라이브러리, 도구, 생태계 등을 포함)의 크기입니다. 사람들은 언어의 사양서가 몇 페이지인지, 키워드가 몇 개인지에 대해 이야기합니다. 이는 사용자가 시스템에서 생산적으로 작업하기 전에 자신의 두뇌에 얼마나 많은 것을 로드해야 하는가입니다. 단순성의 반대말입니다.

복잡성을 줄이는 것은 실제로 파워를 증가시킵니다. 분모가 작을수록 결과값이 커지므로, 단순성이 좋다는 우리의 직관은 타당합니다. 하지만 복잡성을 줄일 때, 그 과정에서 폭이나 용이성을 희생하지 않도록 주의해야 합니다. 그렇지 않으면 전체 파워가 감소할 수 있습니다. Java가 문자열을 제거하면 엄격히 더 단순한 언어가 되겠지만, 아마 텍스트 조작 작업을 잘 처리하지 못할 것이며, 일을 처리하기도 더 어렵게 될 것입니다.

그렇다면 핵심은 생략할 수 있는 우발적 복잡성언어 사용의 폭이나 용이성을 증가시키는 데 그만한 가치를 하지 못하는 언어 기능과 상호작용을 찾아내는 것입니다.

만약 사용자들이 객체 범주(categories of objects)를 기반으로 프로그램을 표현하고자 한다면, 언어에 클래스를 포함시키는 것이 그러한 작업을 더 쉽게 만들 것이며, 바라건대 추가된 복잡성을 상쇄할 만큼 충분히 큰 이점을 제공할 것입니다. 하지만 사용자들이 언어를 그렇게 사용하지 않는다면, 클래스를 빼는 것이 좋습니다.